Repository: washingtonpost/ans-schema (js)

Issue:
Proposal: Scheduled Publish Information
## Problem

Information about future publish events is not included on story objects currently.

## Proposal

Include information about future and past publish/unpublish events.  (E.g., Story API & Content Ops API data)  This data would appear on unpublished versions of stories ONLY.

## Example (Updated)

```
{
    "publishing": {
        "has_published_edition": true,
        "editions": {
            "default": {
                "edition": "default",
                "edition_date": "2017-",
                "first_published_date": "2017-03-",
                "published": false
            },
            "print": {
                "edition": "print"
                "publish_date": "2017-03-03",
                "revision_id": "",
                "published": true
            }
        },        ,
        "operations_scheduled": {
            "publish_edition": [{
                "type": "story_operation",
                "operation": "publish_edition",
                "date": "2017-04-15T15:00:00Z",
                "publish_date": "2017-04-01T",
                "display_date": "2017-04-02T",
                "revision_id": "CAIP33ODLZD5LESQ3CH6MLP6IM",
                "editions": [ "default" ]
            }],
            "unpublish_edition": [{
                "type": "story_operation",
                "operation": "unpublish_edition",
                "date": "2017-04-15T15:00:00Z",
                "editions": [ "default" ]
            }]
        }
    }
}
```



Pull request description:
Scheduled publish
Fixes #122 

Patch:
diff --git a/lib/upvert/0.5.7.js b/lib/upvert/0.5.7.js
index 241c611..5d24072 100644
--- a/lib/upvert/0.5.7.js
+++ b/lib/upvert/0.5.7.js
@@ -135,35 +135,28 @@ var convertComments = function convertComments(old_comments) {
 };
 
 
+// Generate a workflow object from old additional properties
+var workflowFromPlanning = function workflowFromPlanning(old_planning) {
+
+  var workflow = {};
+  var modified = false;
+  if (_.has(old_planning, 'additional_properties.status.workflow')) {
+    workflow.status_code = _.toInteger(_.get(old_planning, 'additional_properties.status.workflow'));
+    modified = true;
+  }
+  if (_.has(old_planning, 'additional_properties.status.note')) {
+    workflow.note = '' + _.get(old_planning, 'additional_properties.status.note');
+    modified = true;
+  }
 
-// var convertCell = function convertCell(old_cell) {
-//   return {
-//     "type": "table_cell",
-//     "content_elements": [
-//       old_cell
-//     ]
-//   };
-// }
-
-
-// var convertTable = function convertTable(old_table) {
-//   // Convert header
-//   old_table.header = {
-//     "type": "table_row",
-//     "cells": _.map(old_table.header, convertCell)
-//   };
-
-//   // Convert rows
-//   old_table.rows = _.map(old_table.rows, function(row, i) {
-//     return {
-//       "type": "table_row",
-//       "cells": _.map(row, convertCell)
-//     };
-//   });
-
-//   return old_table;
-// }
+  if (modified) {
+    return workflow;
+  }
+  else {
+    return null;
+  }
 
+}
 
 // Convert a top-level ANS document and recur at known points
 var convertDocument = function convertDocument(doc) {
@@ -188,6 +181,14 @@ var convertDocument = function convertDocument(doc) {
     else if (key === 'corrections') {
       result[key] = _.map(value, convertCorrection);
     }
+    else if (key === 'planning') {
+      result[key] = convert(value);
+      var workflow = workflowFromPlanning(value);
+      if (!_.isNil(workflow)) {
+        result.workflow = workflow;
+      }
+    }
+
     else if (_.includes(recursion_keys, key)) {
       result[key] = convert(value);
     }
diff --git a/src/main/resources/schema/ans/0.5.8/story.json b/src/main/resources/schema/ans/0.5.8/story.json
index 99d64f9..71f6a35 100644
--- a/src/main/resources/schema/ans/0.5.8/story.json
+++ b/src/main/resources/schema/ans/0.5.8/story.json
@@ -100,7 +100,7 @@
     },
     "pitches": {
       "$ref": "https://raw.githubusercontent.com/washingtonpost/ans-schema/master/src/main/resources/schema/ans/0.5.8/traits/trait_pitches.json"
-    },    
+    },
     "revision": {
       "$ref": "https://raw.githubusercontent.com/washingtonpost/ans-schema/master/src/main/resources/schema/ans/0.5.8/traits/trait_revision.json"
     },
@@ -133,6 +133,9 @@
     },
     "content_elements": {
       "$ref": "https://raw.githubusercontent.com/washingtonpost/ans-schema/master/src/main/resources/schema/ans/0.5.8/traits/trait_content_elements.json"
+    },
+    "publishing": {
+      "$ref": "https://raw.githubusercontent.com/washingtonpost/ans-schema/master/src/main/resources/schema/ans/0.5.8/traits/trait_publishing.json"
     }
   },
   "required": [ "type", "version" ]
diff --git a/src/main/resources/schema/ans/0.5.8/story_operation.json b/src/main/resources/schema/ans/0.5.8/story_operation.json
index 4d3aaee..1e28317 100644
--- a/src/main/resources/schema/ans/0.5.8/story_operation.json
+++ b/src/main/resources/schema/ans/0.5.8/story_operation.json
@@ -2,67 +2,66 @@
   "$schema": "http://json-schema.org/draft-04/schema#",
   "id": "https://raw.githubusercontent.com/washingtonpost/ans-schema/master/src/main/resources/schema/ans/0.5.8/story_operation.json",
   "description": "An operation on a story",
+  "additionalProperties": false,
   "type": "object",
-  "allOf": [{
-    "properties": {
-      "type": {
-        "description": "Identifies this as an ANS operation",
-        "type": "string",
-        "enum": [ "story_operation" ]
-      },
-      "operation": {
-        "type": "string",
-        "description": "The identifier of the operation being performed",
-        "enum": [ "create", "update", "delete", "publish_edition", "unpublish_edition" ]
-      },
-      "date": {
-        "description": "When the operation should be considered performed",
-        "type": "string",
-        "format": "date-time"
-      },
-      "display_date":{
-        "description": "The date that is displayed with this story to readers.",
-        "type": "string",
-        "format": "date-time"
-      },
-      "publish_date":{
-        "description": "When the story was published.",
-        "type": "string",
-        "format": "date-time"
-      },
-      "submit_date": {
-        "description": "When this operation was submitted to Content API. Used for latency checking.",
-        "type":"string",
-        "format": "date-time"
-      },
-      "story_id": {
-        "type": "string",
-        "description": "The id of the story being operated"
-      },
-      "revision_id": {
-        "type": "string",
-        "description": "The id of the specific revision that this operation occurs on, if any"
-      },
-      "editions": {
-        "type": "array",
-        "description": "A list of identifiers of editions that are changed in this operation, if any",
-        "items": {
-          "type": "string"
-        }
-      },
-      "branch": {
-        "type": "string",
-        "description": "The name of the branch within Story API that this operation occurs on, if any"
-      },
-      "version": {
-        "type": "string",
-        "description": "The version of ANS this Story is written in"
-      },
-      "story": {
-        "description": "The story object being created/updated/deleted/published/unpublished",
-        "$ref": "https://raw.githubusercontent.com/washingtonpost/ans-schema/master/src/main/resources/schema/ans/0.5.8/story.json"
+  "properties": {
+    "type": {
+      "description": "Identifies this as an ANS operation",
+      "type": "string",
+      "enum": [ "story_operation" ]
+    },
+    "operation": {
+      "type": "string",
+      "description": "The identifier of the operation being performed",
+      "enum": [ "create", "update", "delete", "publish_edition", "unpublish_edition" ]
+    },
+    "date": {
+      "description": "When the operation should be considered performed",
+      "type": "string",
+      "format": "date-time"
+    },
+    "display_date":{
+      "description": "The date that is displayed with this story to readers.",
+      "type": "string",
+      "format": "date-time"
+    },
+    "publish_date":{
+      "description": "When the story was published.",
+      "type": "string",
+      "format": "date-time"
+    },
+    "submit_date": {
+      "description": "When this operation was submitted to Content API. Used for latency checking.",
+      "type":"string",
+      "format": "date-time"
+    },
+    "story_id": {
+      "type": "string",
+      "description": "The id of the story being operated"
+    },
+    "revision_id": {
+      "type": "string",
+      "description": "The id of the specific revision that this operation occurs on, if any"
+    },
+    "editions": {
+      "type": "array",
+      "description": "A list of identifiers of editions that are changed in this operation, if any",
+      "items": {
+        "type": "string"
       }
     },
-    "required": [ "type", "operation", "story_id" ]
-  }]
+    "branch": {
+      "type": "string",
+      "description": "The name of the branch within Story API that this operation occurs on, if any"
+    },
+    "version": {
+      "type": "string",
+      "description": "The version of ANS this Story is written in"
+    },
+    "story": {
+      "description": "The story object being created/updated/deleted/published/unpublished",
+      "$ref": "https://raw.githubusercontent.com/washingtonpost/ans-schema/master/src/main/resources/schema/ans/0.5.8/story.json"
+    }
+  },
+  "required": [ "type", "operation", "story_id" ]
 }
diff --git a/src/main/resources/schema/ans/0.5.8/traits/trait_publishing.json b/src/main/resources/schema/ans/0.5.8/traits/trait_publishing.json
new file mode 100644
index 0000000..8dd6949
--- /dev/null
+++ b/src/main/resources/schema/ans/0.5.8/traits/trait_publishing.json
@@ -0,0 +1,135 @@
+{
+  "$schema": "http://json-schema.org/draft-04/schema#",
+  "id": "https://raw.githubusercontent.com/washingtonpost/ans-schema/master/src/main/resources/schema/ans/0.5.8/traits/trait_publishing.json",
+  "title": "Publishing Information",
+  "description": "The current published state of all editions of a content item as well as any scheduled publishing information. Machine-generated.",
+  "type": "object",
+  "additionalProperties": false,
+
+  "properties": {
+
+    "has_published_edition": {
+      "title": "Has Published Edition",
+      "description": "True if and only if at least one published edition exists for this content item.",
+      "type": "boolean"
+    },
+
+    "editions": {
+      "type": "object",
+      "description": "A map of edition names to the current publish state for that edition",
+      "properties": {
+        "default": {
+          "$ref": "#/definitions/edition"
+        }
+      },
+      "patternProperties": {
+        "^[a-zA-Z0-9_]*$": {
+          "$ref": "#/definitions/edition"
+        }
+      },
+      "required": [ "default" ]
+    },
+
+    "scheduled_operations": {
+      "title": "Scheduled Operations",
+      "description": "A map of lists of operations scheduled to be performed on this content item, sorted by operation type.",
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "publish_edition": {
+          "type": "array",
+          "items": {
+            "type": "object",
+            "allOf": [
+              {
+                "$ref": "https://raw.githubusercontent.com/washingtonpost/ans-schema/master/src/main/resources/schema/ans/0.5.8/story_operation.json"
+              },
+              {
+                "type": "object",
+                "properties": {
+                  "operation": {
+                    "enum": [ "publish_edition" ]
+                  }
+                }
+              }
+            ]
+          }
+        },
+        "unpublish_edition": {
+          "type": "array",
+          "items": {
+            "type": "object",
+            "allOf": [
+              {
+                "$ref": "https://raw.githubusercontent.com/washingtonpost/ans-schema/master/src/main/resources/schema/ans/0.5.8/story_operation.json"
+              },
+              {
+                "properties": {
+                  "operation": {
+                    "enum": [ "unpublish_edition" ]
+                  }
+                }
+              }
+            ]
+          }
+        }
+      }
+    }
+  },
+
+  "required": [ "has_published_edition" ],
+
+  "definitions": {
+    "edition": {
+      "properties": {
+
+        "edition": {
+          "title": "Edition Name",
+          "description": "The machine-readable identifier of this edition. This should be the same as the key in 'editions' for the edition object.",
+          "type": "string"
+        },
+
+        "edition_date": {
+          "title": "Edition Date",
+          "description": "The machine-generated date that this edition was last updated (i.e., that the content item was published/unpublished to a particular destination.)",
+          "type": "string",
+          "format": "date-time"
+        },
+
+        "first_published_date": {
+          "title": "First Published Date",
+          "description": "The machine-generated date that this edition was created for the first time (i.e., that the content item was first published.)",
+          "type": "string",
+          "format": "date-time"
+        },
+
+        "display_date": {
+          "title": "Display Date (Edition)",
+          "description": "The human-editable date that should be shown to readers as the 'date' for this content item. When viewing the story at this edition name directly, this will override whatever value is set for Display Date on the story directly. After an edition is created, subsequent updates to that edition will not change this date unless otherwise specified.",
+          "type": "string",
+          "format": "date-time"
+        },
+
+        "publish_date": {
+          "title": "Publish Date (Edition)",
+          "description": "The machine-editable date that should be shown to readers as the 'publish date' for this content item. When viewing the story at this edition name directly, this will override whatever value is set for Publish Date on the story directly. Every time an edition is updated (i.e. a story is republished) this date will also be updated unless otherwise specified.",
+          "type": "string",
+          "format": "date-time"
+        },
+
+        "published": {
+          "title": "Publish Status",
+          "description": "If false, this edition has been deleted/unpublished.",
+          "type": "boolean"
+        },
+
+        "revision_id": {
+          "title": "Revision ID",
+          "description": "The id of the revision that this edition was created from. Omitted if unpublished.",
+          "type": "string"
+        }
+      },
+      "required": [ "published", "edition_date", "edition" ]
+    }
+  }
+}


Test patch:
diff --git a/tests/fixtures/schema/0.5.8/story-fixture-tiny-house.json b/tests/fixtures/schema/0.5.8/story-fixture-tiny-house.json
index b0831cc..bb7b134 100644
--- a/tests/fixtures/schema/0.5.8/story-fixture-tiny-house.json
+++ b/tests/fixtures/schema/0.5.8/story-fixture-tiny-house.json
@@ -136,5 +136,47 @@
     "additional_properties": {
       "foo": "bar"
     }
+  },
+  "publishing": {
+    "has_published_edition": true,
+    "editions": {
+      "default": {
+        "edition": "default",
+        "edition_date": "2017-04-15T15:00:00Z",
+        "first_published_date": "2017-04-15T12:00:00Z",
+        "publish_date": "2017-04-15T12:00:00Z",
+        "display_date": "2017-04-15T15:00:00Z",
+        "revision_id": "CAIP33ODLZD5LESQ3CH6MLP6IM",
+        "published": true
+      },
+      "print": {
+        "edition": "print",
+        "edition_date": "2017-04-16T15:00:00Z",
+        "first_publish_date": "2017-04-16T15:00:00Z",
+        "publish_date": "2017-04-16T15:00:00Z",
+        "display_date": "2017-04-16T15:00:00Z",
+        "revision_id": "CAIP33ODLZD5LESQ3CH6MLP6IM",
+        "published": true
+      }
+    },
+    "scheduled_operations": {
+      "publish_edition": [{
+        "type": "story_operation",
+        "story_id": "f8f706f0-0acc-11e5-9e39-0db921c47b93",
+        "operation": "publish_edition",
+        "date": "2017-04-20T15:00:00Z",
+        "publish_date": "2017-04-15T15:00:00Z",
+        "display_date": "2017-04-15T15:00:00Z",
+        "revision_id": "DDDP33ODLZD5LESQ3CH6MLP6IM",
+        "editions": [ "default" ]
+      }],
+      "unpublish_edition": [{
+        "type": "story_operation",
+        "story_id": "f8f706f0-0acc-11e5-9e39-0db921c47b93",
+        "operation": "unpublish_edition",
+        "date": "2017-04-30T15:00:00Z",
+        "editions": [ "default" ]
+      }]
+    }
   }
 }
diff --git a/tests/fixtures/schema/0.5.8/trait-publishing-fixture-bad-mismatched-operations.json b/tests/fixtures/schema/0.5.8/trait-publishing-fixture-bad-mismatched-operations.json
new file mode 100644
index 0000000..2b4197d
--- /dev/null
+++ b/tests/fixtures/schema/0.5.8/trait-publishing-fixture-bad-mismatched-operations.json
@@ -0,0 +1,10 @@
+{
+  "has_published_edition": true,
+  "scheduled_operations": {
+    "publish_edition": [{
+      "type": "story_operation",
+      "operation": "delete",
+      "story_id": "ABC"
+    }]
+  }
+}
diff --git a/tests/fixtures/transforms/0.5.7/story-fixture-planning-status-code-is-string.json b/tests/fixtures/transforms/0.5.7/story-fixture-planning-status-code-is-string.json
new file mode 100644
index 0000000..159a7bb
--- /dev/null
+++ b/tests/fixtures/transforms/0.5.7/story-fixture-planning-status-code-is-string.json
@@ -0,0 +1,37 @@
+{
+  "type": "story",
+  "version": "0.5.7",
+  "content_elements": [
+    {
+      "type": "text",
+      "content": "Plans are in motion."
+    }
+  ],
+  "headlines": {
+    "basic": "A simple story with big plans.."
+  },
+
+  "planning": {
+    "scheduling": {
+      "planned_publish_date": "2015-06-24T09:49:00.10Z",
+      "scheduled_publish_date": "2015-06-24T09:49:00.10Z",
+      "will_have_gallery": true,
+      "will_have_graphic": false,
+      "will_have_image": true,
+      "will_have_video": false
+    },
+    "story_length": {
+      "word_count_planned": 2400,
+      "word_count_actual": 3000,
+      "inch_count_planned": 6,
+      "inch_count_actual": 8
+    },
+    "additional_properties": {
+      "status": {
+        "workflow": "2",
+        "note": "This is a private note."
+      }
+    }
+  }
+
+}
diff --git a/tests/fixtures/transforms/0.5.7/story-fixture-planning.json b/tests/fixtures/transforms/0.5.7/story-fixture-planning.json
new file mode 100644
index 0000000..fdf3d21
--- /dev/null
+++ b/tests/fixtures/transforms/0.5.7/story-fixture-planning.json
@@ -0,0 +1,37 @@
+{
+  "type": "story",
+  "version": "0.5.7",
+  "content_elements": [
+    {
+      "type": "text",
+      "content": "Plans are in motion."
+    }
+  ],
+  "headlines": {
+    "basic": "A simple story with big plans.."
+  },
+
+  "planning": {
+    "scheduling": {
+      "planned_publish_date": "2015-06-24T09:49:00.10Z",
+      "scheduled_publish_date": "2015-06-24T09:49:00.10Z",
+      "will_have_gallery": true,
+      "will_have_graphic": false,
+      "will_have_image": true,
+      "will_have_video": false
+    },
+    "story_length": {
+      "word_count_planned": 2400,
+      "word_count_actual": 3000,
+      "inch_count_planned": 6,
+      "inch_count_actual": 8
+    },
+    "additional_properties": {
+      "status": {
+        "workflow": 2,
+        "note": "This is a private note."
+      }
+    }
+  }
+
+}
diff --git a/tests/schema-tests-05.js b/tests/schema-tests-05.js
index 0d31e63..48256b7 100644
--- a/tests/schema-tests-05.js
+++ b/tests/schema-tests-05.js
@@ -414,7 +414,8 @@ var validate = function(version, schemaName, fixture, expected) {
 
   var result = ajv.validate(schema, fixture);
   if (result !== expected) {
-    console.log(fixture);
+    console.log(JSON.stringify(fixture));
+    console.log(JSON.stringify(schema));
     console.log(ajv.errors);
   }
   result.should.eql(expected);
@@ -779,6 +780,15 @@ describe("Schema: ", function() {
           });
         });
 
+        describe("Publishing (0.5.8+)", function() {
+          it("should validate publishing information", function() {
+            validateIfFixtureExists(version, '/traits/trait_publishing.json', 'trait-publishing-fixture-good', true);
+          })
+
+          it('should not validate publishing with incorrect story operation types', function() {
+            validateIfFixtureExists(version, '/traits/trait_publishing.json', 'trait-publishing-fixture-bad-mismatched-operations', false);
+          });
+        });
 
         describe("Related Content (0.5.8+)", function() {
           it("should not validate related_content.redirect that is not a redirect or that has too many items", function() {
diff --git a/tests/transform-tests.js b/tests/transform-tests.js
index 15c3028..4d1cedd 100644
--- a/tests/transform-tests.js
+++ b/tests/transform-tests.js
@@ -621,6 +621,39 @@ describe("Transformations: ", function() {
         });
       });
 
+      describe("Planning", function() {
+        it("should preserve the planning object fields", function() {
+          var result = transforms.upvert(fixtures['0.5.7']['story-fixture-planning'], '0.5.8');
+
+          result.planning.scheduling.planned_publish_date.should.eql("2015-06-24T09:49:00.10Z");
+          result.planning.scheduling.scheduled_publish_date.should.eql("2015-06-24T09:49:00.10Z");
+          result.planning.story_length.word_count_planned.should.eql(2400);
+          result.planning.story_length.inch_count_actual.should.eql(8);
+          result.planning.additional_properties.status.workflow.should.eql(2);
+          result.planning.additional_properties.status.note.should.eql("This is a private note.");
+
+        });
+
+        it("should add a workflow object", function() {
+          var result = transforms.upvert(fixtures['0.5.7']['story-fixture-planning'], '0.5.8');
+
+          result.workflow.status_code.should.eql(2);
+          result.workflow.note.should.eql("This is a private note.");
+        });
+
+        it("should add a workflow object even when status code is a string", function() {
+          var result = transforms.upvert(fixtures['0.5.7']['story-fixture-planning-status-code-is-string'], '0.5.8');
+
+          result.workflow.status_code.should.eql(2);
+          result.workflow.note.should.eql("This is a private note.");
+        });
+
+      });
+
+      it("should not drop fields", function() {
+        var result = transforms.upvert(fixtures['0.5.7']['story-fixture-planning'], '0.5.8');
+        result.headlines.basic.should.eql("A simple story with big plans..");
+      });
 
     });
   });