Table of Contents
- 1 The Business Situation
- 2 The Existing Process
- 2.1 Coordination Problems
- 2.2 Practical Business Effects
- 3 What the New System Needed to Do
- 4 Implementation Approaches Considered
- 5 The Selected Solution
- 6 System Architecture and Data Flow
- 7 Data Structure
- 7.1 Related Sheets
- 8 Workflow Statuses and Ownership
- 9 Step-by-Step Implementation
- 9.1 Step 1: Prepare the Accounts and Permissions
- 9.2 Step 2: Build the Intake
- 9.3 Step 3: Create the System of Record
- 9.4 Step 4: Connect the Tools
- 9.5 Step 5: Build the Core Automation
- 9.6 Step 6: Add Approvals, Reminders, and Escalations
- 9.7 Step 7: Add Documents and File Management
- 9.8 Step 8: Add Reporting and Operational Views
- 9.9 Step 9: Add Security and Governance Controls
- 9.10 Step 10: Deploy and Test
- 10 Code and Configuration
- 10.1 Configuration and Deployment Instructions
- 11 Failure Handling and Operational Reliability
- 12 A Complete Example
- 13 Implementation Cost
- 14 Estimated Time and Cost Savings
- 15 Adding AI to the Automation
- 15.1 The Recommended AI Enhancement
- 15.2 Benefits of the AI Enhancement
- 15.3 What Remains Rule-Based or Human-Controlled
- 15.4 Estimating the Additional Value of AI
- 16 Testing Checklist
- 17 Ongoing Maintenance
- 18 When to Move to Dedicated Software
- 19 Implementation Checklist
The Business Situation
Juniper Vale Software is a fictional 58-person B2B software company with a six-person marketing department. The team includes a marketing operations manager, two content marketers, a product marketer, a designer, and a marketing leader who provides final approval. Product managers and customer-facing specialists participate as subject reviewers.
Note: This case study is provided as a representative example of the types of AI integration and digital transformation solutions Intelligex designs and delivers. Actual engagements are tailored to each client’s goals, constraints, existing systems, timeline, and available resources, so the approach, tools, and outcomes may vary.
The marketing department coordinates approximately 42 content items per month. Typical work includes blog articles, customer stories, product pages, webinars, email campaigns, and supporting social posts.
The company already uses Google Workspace. Ideas arrive through chat messages, email, meeting notes, and an informal Google Sheet. Briefs are drafted in Google Docs, while designs and supporting files are stored in different Google Drive folders. Publishing remains a controlled manual action in the company’s content management system.
There was no single record connecting the original request, content owner, draft, design, subject review, final approval, target date, publishing checks, and final URL. The marketing operations manager spent considerable time asking for updates and reconciling conflicting status information.
The team needed to change the process because content volume was rising, deadlines were becoming less predictable, and the spreadsheet could not reliably show which person needed to act next. The goal was not to automate editorial judgment. It was to automate intake, standardization, assignment, reminders, status control, asset organization, and reporting around that judgment.
The Existing Process
The original workflow followed a recognizable sequence, but each step was maintained through a different combination of chat, email, documents, and spreadsheet entries.
- A marketer posted an idea in a chat channel or mentioned it during a planning meeting.
- The marketing operations manager copied selected ideas into a planning spreadsheet.
- A content marketer created a brief in Google Docs and added its link to the spreadsheet.
- The marketer contacted a product specialist or customer-facing employee for subject review.
- Review feedback arrived as document comments, email messages, or chat replies.
- The designer received a separate request, often without the latest copy or final dimensions.
- The content owner completed SEO and publishing checks using a personal checklist.
- The marketing leader approved the content by email or chat.
- A marketer scheduled or published the item in the content management system.
- The spreadsheet was updated with the final URL when someone remembered to do so.
Coordination Problems
- Ideas arrived in several channels with different levels of detail.
- Owners and reviewers were recorded in free-text cells.
- Deadlines were sometimes target publishing dates and sometimes internal review dates.
- Approval evidence was dispersed across email, chat, and document comments.
- Follow-up depended heavily on the marketing operations manager.
Practical Business Effects
- Incomplete briefs created avoidable clarification cycles.
- Unclear ownership delayed reviews and design work.
- Late-stage SEO issues caused scheduled dates to move.
- Reporting required manual spreadsheet cleanup.
- Leadership could not reliably compare planned, active, overdue, and published work.
Duplicate data entry was especially common. A title, campaign, owner, and date might appear in the planning sheet, a brief document, a design request, and a publishing checklist. When one location changed, the other versions were not automatically updated.
The spreadsheet also lacked controlled statuses. Entries such as review, with product, almost done, and waiting could mean different things. These descriptions did not identify the current owner, required action, or deadline.
Finally, the process had weak operational history. The team could see a current status if the spreadsheet was updated, but it could not reliably answer when a record entered review, who approved it, why it was returned, or whether a reminder had been sent.
What the New System Needed to Do
The team defined business requirements before selecting technology. The requirements deliberately separated deterministic workflow rules from editorial decisions that required human judgment.
| Requirement | Expected behavior | Control type |
|---|---|---|
| Standardized intake | Collect objectives, audience, type, channel, campaign, priority, dates, reviewers, and asset links through required fields. | Form validation |
| Unique identification | Assign one permanent content record ID and detect repeated submissions. | Automation rule |
| System of record | Maintain one row per content item with controlled fields and timestamps. | Structured data |
| Ownership | Assign a content owner and change the current owner as work enters review, design, approval, and publishing. | Lookup rule |
| Review gates | Prevent a record from advancing when a required draft, approval, design, or publishing check is missing. | Workflow rule |
| Deadlines | Calculate stage deadlines from the requested publishing date and identify overdue work. | Date rule |
| Documents | Create one Drive folder per content record and link briefs, drafts, designs, and source assets. | Automation |
| Notifications | Notify the next owner, send overdue reminders, and escalate unresolved delays. | Automation |
| Publishing control | Require final human approval and publishing checks before a record can be scheduled. | Human approval plus rules |
| Reporting | Show volume, status, owner, overdue work, exceptions, cycle time, and planned publishing dates. | Dashboard |
| Audit evidence | Record status changes, approvals, automation runs, and failures. | Append-only logs |
| Manual override | Allow authorized staff to block, cancel, reassign, or return an item with a recorded reason. | Human control |
| Optional AI assistance | Generate reviewed outline and repurposing suggestions without making approval or publishing decisions. | Human-reviewed AI |
The system also needed to handle incomplete records, duplicate events, unavailable reviewers, failed notifications, inaccessible Drive folders, expired credentials, and malformed AI responses. A failed automation could not leave the record looking successfully processed.
Implementation Approaches Considered
| Approach | Connected tools | Strengths | Effort | Primary limitation |
|---|---|---|---|---|
| Improve the existing spreadsheet | Google Sheets and manual email | Minimal platform change | Low initially | Does not solve ownership, controls, audit history, or reminder dependency |
| Google Workspace automation | Forms, Sheets, Apps Script, Drive, Gmail, Looker Studio | Fits the existing environment and supports custom gates and logging | Moderate | Requires script ownership, testing, and maintenance |
| Airtable workflow | Airtable forms, relational tables, automations, interfaces | Strong structured views, relationships, and editorial calendar options | Moderate | Adds another governed platform and may require integration with existing Drive assets |
| Notion editorial workspace | Notion databases, pages, templates, and external automation | Good collaboration between briefs, notes, and content pages | Moderate | Complex validation and transactional workflow controls may require an additional automation layer |
| Microsoft 365 workflow | Microsoft Forms, Lists, SharePoint, Power Automate, Teams, Power BI | Strong option for organizations already governed through Microsoft 365 | Moderate | Would duplicate Juniper Vale Software’s current Google environment |
| Cross-platform automation service | Zapier, Make, or n8n connected to existing applications | Useful when records must cross several unrelated systems | Moderate to high | Adds a separate credential, monitoring, and recurring-cost layer when native scripting is sufficient |
Simply cleaning up the existing spreadsheet was rejected because it retained the same dependence on manual updates and follow-ups. Dropdowns would improve consistency, but they would not create folders, enforce stage gates, send reminders, or write audit records.
Airtable was a credible alternative. Its relational records and multiple views are well suited to editorial operations. It was not selected because the representative company already had governed Google accounts, Drive storage, and a reporting practice based on Looker Studio. Introducing Airtable would require another permission model and a decision about whether attachments belonged in Airtable or Drive.
Notion was attractive for combining editorial documentation and project records. It was less suitable for this scenario because the team wanted the operational record to behave like controlled data rather than a flexible page. External automation would still be required for stronger validation, reminders, and failure logging.
The Microsoft 365 design could provide comparable functionality with Microsoft Lists, SharePoint, Power Automate, and Power BI. It would be more appropriate for a company already operating in Microsoft 365. It was not appropriate to replace an established Google environment solely for this workflow.
Zapier, Make, or n8n would become more compelling if the workflow needed direct connections to a separate project management system, digital asset management platform, customer relationship management system, or content management system. The initial implementation did not require that extra platform because Apps Script could connect the selected Google services.
The Selected Solution
Juniper Vale Software selected a Google Workspace implementation built around Google Forms, Google Sheets, Apps Script, Google Drive, Gmail, and Looker Studio. The optional AI enhancement uses an approved external AI API only when an authorized content owner requests assistance.
| Tool | Responsibility | Reason retained or selected |
|---|---|---|
| Google Forms | Standardized content request intake | Provides required fields, controlled choices, dates, and a direct response destination |
| Google Sheets | System of record, configuration, users, audit logs, error logs, and calendar data | Accessible to the marketing team and compatible with Apps Script and Looker Studio |
| Google Apps Script | Validation, standardization, identifiers, assignment, status gates, reminders, folder creation, logging, and optional AI calls | Supports custom rules without introducing another automation platform |
| Google Drive | Record-level folders, briefs, drafts, designs, and AI assistance documents | Retains the company’s existing document storage and permission model |
| Gmail through Apps Script | Assignment, approval, reminder, escalation, and failure notifications | Uses authenticated Workspace identities and avoids another notification connection |
| Looker Studio | Operational dashboard and publishing calendar views | Reads the standardized Sheets data and supports filters and calculated fields |
| Approved AI API | Optional outline and repurposing suggestions | Used only after the core workflow is reliable and only with human review |
The company retained Google Docs for writing, its existing design application for creative production, and its content management system for publishing. Those tools were linked to the content record rather than replaced.
Manual copying, free-text status updates, routine folder creation, owner lookups, reminder emails, and dashboard preparation were removed. Editorial direction, subject accuracy, design approval, final marketing approval, and the actual publishing action remained human-controlled.
System Architecture and Data Flow
The architecture uses Google Sheets as the central operational record. Google Forms captures requests, Apps Script processes events and applies rules, Google Drive stores assets, Gmail delivers notifications, and Looker Studio reads standardized reporting tables.
- Form submission: A requester submits a Google Form. The spreadsheet form-submit trigger receives the response row. Required fields and allowed values are checked again in Apps Script, even though the form already performs front-end validation.
- Standardization: Apps Script trims text, converts the requested date into a real date value, maps the content type to a reporting category, builds a concise deterministic summary, and calculates a duplicate key.
- Duplicate check: The script compares the duplicate key with existing content records. A repeated event returns the existing record ID and does not create another operational record.
- Record creation: The script generates an ID such as
CNT-2026-0043, assigns a content owner from the Users sheet, calculates the first stage deadline, and appends the record to the Content Tracker sheet. - Asset storage: Apps Script creates a Drive folder named with the record ID and working title. The returned folder URL is written to the record.
- Notification: The requester and content owner receive the new record ID, title, status, requested publishing date, and link to the asset folder.
- Workflow progression: Authorized users change controlled status and review fields in the Content Tracker. An installable edit trigger validates transitions, updates ownership and dates, and rejects changes that bypass required gates.
- Review and approval: Subject approval can advance the record to design and SEO. Final human approval can advance it to Scheduled only when the publishing checklist passes.
- Publishing: A marketer publishes in the content management system, records the final URL and optional CMS identifier, and moves the item to Published.
- Monitoring: A daily trigger sends reminders, escalates overdue stages, retries selected integration failures, and rebuilds the Calendar View sheet.
- Reporting: Looker Studio reads the Content Tracker and Calendar View. Dashboard users filter by owner, status, campaign, channel, category, priority, and publishing date.
- Failure path: Failures are written to the Error Log and the affected record is marked
Retry RequiredorManual Review. Staff can correct the data and rerun the appropriate action without creating a new content record.
- Intake: Google Forms
- System of record: Google Sheets Content Tracker
- Automation layer: Google Apps Script installable triggers and menu actions
- Document storage: Google Drive folders and Google Docs
- Notifications: Gmail messages sent through Apps Script
- Reporting: Looker Studio connected to Google Sheets
- AI layer: Optional, manually invoked API request with structured output and human review
Data Structure
The spreadsheet contains related sheets rather than one overloaded worksheet. The raw Google Forms response sheet is retained as submitted evidence. The Content Tracker contains one row per content item. Audit, error, and AI logs contain multiple rows related back to the Content Tracker through Record ID.
| Field | Type | Required | Source or updater | Purpose and validation |
|---|---|---|---|---|
| Record ID | Text | Yes | Apps Script | Unique identifier in the format CNT-year-sequence |
| Brief Key | Text | Yes | Apps Script | Hashed duplicate key derived from requester, title, campaign, and requested date |
| Created Date | Date and time | Yes | Apps Script | Original record creation timestamp |
| Last Updated | Date and time | Yes | Apps Script | Timestamp of the latest controlled update |
| Requester Name and Email | Text | Yes | Google Form | Identifies the person who submitted the request; email format is validated |
| Working Title | Text | Yes | Google Form | Human-readable content name; not treated as a unique key by itself |
| Business Objective | Long text | Yes | Google Form | Explains the intended result and is required before drafting |
| Audience | Text | Yes | Google Form | Defines the intended reader or participant |
| Content Type | Controlled text | Yes | Google Form | Allowed values include Blog Article, Case Study, Product Page, Webinar, Email Campaign, and Social Post |
| Channel | Controlled text | Yes | Google Form | Allowed values include Website, Email, Social, Webinar, and Sales Enablement |
| Category | Controlled text | Yes | Apps Script | Standardized reporting category mapped from content type |
| Priority | Controlled text | Yes | Google Form | Standard, High, or Urgent; Urgent requires a reason |
| Requested Publish Date | Date | Yes | Google Form | Target date used to calculate stage deadlines |
| Stage Due Date | Date | Yes while active | Apps Script | Current internal deadline based on status and target publishing date |
| Status | Controlled text | Yes | Apps Script and authorized editors | Current workflow stage; invalid transitions are rejected |
| Content Owner | Name and email | Yes | Users lookup | Permanent editorial owner responsible for the content item |
| Owner | Name and email | Yes | Apps Script | Person currently responsible for the next action |
| Subject Review Status | Controlled text | Yes | Subject reviewer | Pending, Approved, or Changes Requested |
| Approval Status | Controlled text | Yes | Final approver | Pending, Approved, Changes Requested, or Rejected |
| Draft Link and Design Link | URL | Conditional | Content owner and designer | Required by relevant workflow gates |
| Asset Folder Link | URL | Yes | Apps Script | Points to the record-specific Drive folder |
| SEO and publishing checks | Text and Yes/No fields | Conditional | Content owner | Includes SEO title, meta description, keyword, links checked, alt text, and proofread confirmation |
| Published URL | URL | Required for Published | Publisher | Prevents closure without a final destination |
| External CMS ID | Text | No | Publisher | Stores an identifier returned by or copied from the content management system |
| Exception Type | Text | No | Apps Script or operator | Describes blocked gates, integration failures, or policy exceptions |
| Automation Status | Controlled text | Yes | Apps Script | Processing, Complete, Retry Required, or Manual Review |
| Last Automation Run | Date and time | Yes | Apps Script | Supports monitoring and reconciliation |
| Retry Count and Error Message | Number and text | No | Apps Script | Tracks automated recovery attempts and the latest failure |
| AI Status, Confidence, and Output Link | Controlled text, number, URL | No | Optional AI action | Records whether AI assistance requires review, was accepted, rejected, or failed |
Related Sheets
- Form Responses
- Raw submissions plus processing status and returned Record ID. Marketing staff do not use this as the operational tracker.
- Users
- Active users, roles, category coverage, channel coverage, and backup email addresses used for assignment.
- Config
- Drive folder ID, escalation timing, AI endpoint, approved model name, and operational email configuration.
- Audit Log
- Append-only events containing timestamp, Record ID, event name, actor, and concise details.
- Error Log
- Failures containing the affected step, message, stack information, context, and resolution fields.
- Calendar View
- Derived rows sorted by requested publishing date, including week start, day name, status, owner, and links.
- AI Log
- Model, request ID, token usage, output status, confidence, and character counts. It does not store the full prompt or content.
Record ID creates a one-to-many relationship between Content Tracker and each log. Drive folders have a one-to-one relationship with content records. The Calendar View is a derived reporting table and must not be edited directly.
Workflow Statuses and Ownership
| Status | Owner | Entry condition | Exit condition | Reminder and escalation |
|---|---|---|---|---|
| Needs Information | Requester | Brief requires clarification or is returned | Required information is added and content owner accepts the brief | Reminder after the stage due date; marketing operations receives escalation after two overdue days |
| Brief Ready | Content owner | Validated form submission creates the record | Owner starts drafting or returns the brief | Daily after due date |
| Drafting | Content owner | Brief is accepted or review changes are requested | A valid draft link is recorded | Daily after due date |
| Subject Review | Subject reviewer | Draft link exists | Reviewer approves or requests changes | Daily reminder; escalation can include reviewer backup and marketing operations |
| Design and SEO | Designer or content owner | Subject Review Status is Approved | Required design assets and SEO fields are complete | Daily after due date |
| Final Approval | Final approver | Draft exists and required design asset exists | Approver approves, requests changes, or rejects | Daily after due date; unavailable approver can be reassigned by marketing operations |
| Scheduled | Marketing operations | Approval is Approved and every required publishing check passes | Final URL is recorded after publishing | Alert when requested date is approaching or overdue |
| Published | Marketing operations | Published URL is present | Closed unless reopened under a controlled correction process | No routine reminder |
| Blocked | Assigned by marketing operations | An external dependency prevents progress | Blocker is resolved and an authorized stage is selected | Daily review and escalation |
| Cancelled | Marketing operations | Authorized cancellation or final rejection | Closed | No routine reminder |
A record moves backward when a reviewer requests changes, a publishing check fails, or the content owner identifies missing information. Final rejection moves the record to Cancelled. A temporary dependency moves it to Blocked without losing the record history.
Changing a dropdown is not sufficient to bypass a gate. The Apps Script edit trigger reads the complete row, checks the requested transition, and restores the previous status when required evidence is missing.
Step-by-Step Implementation
Step 1: Prepare the Accounts and Permissions
- Create or identify the Google Workspace account that will own the form, spreadsheet, Apps Script project, reporting data sources, and root Drive folder. Use a managed operational account rather than a departing employee’s personal account.
- Create a shared Drive folder named
Marketing Content Operations. If shared drives are unavailable, use a centrally owned folder with restricted membership and documented ownership. - Grant the marketing operations manager edit access to the spreadsheet and root folder. Content owners need edit access to operational fields. Reviewers may receive edit access to specific review fields or use protected ranges and a separate review interface.
- Grant dashboard viewers read access to the reporting spreadsheet or use the data-credential controls appropriate to the organization’s Looker Studio policy.
- Create test identities for a requester, content owner, subject reviewer, final approver, and marketing operations administrator. Do not test approval paths using one identity for every role.
- Confirm that the Apps Script owner can use Spreadsheet, Drive, Document, Mail, external request, and trigger services. Authorization prompts will reflect the services used by the installed script.
- If the optional AI action will be enabled, obtain an approved API key and model name. Store the key in Apps Script project properties under
OPENAI_API_KEY. Do not put the key in the spreadsheet. - Create a separate test form and spreadsheet copy for development. Production triggers should not point to the test spreadsheet.
Subscription requirements depend on the organization’s Google Workspace configuration, Drive model, Looker Studio requirements, security controls, and AI provider. Confirm that the selected accounts support the required sharing, automation, and reporting features rather than assuming a particular plan includes them.
Step 2: Build the Intake
Create a Google Form named Content Request. Connect it to the implementation spreadsheet as its response destination. Use the following questions exactly if the supplied script will be used without changing its field mapping.
| Question | Type | Required | Validation or values |
|---|---|---|---|
| Requester Name | Short answer | Yes | Non-empty |
| Requester Email | Short answer | Yes | Email format |
| Working Title | Short answer | Yes | Use a descriptive working title |
| Business Objective | Paragraph | Yes | Describe the business result, not only the topic |
| Primary Audience | Short answer | Yes | Role, segment, or customer group |
| Content Type | Dropdown | Yes | Blog Article, Case Study, Product Page, Webinar, Email Campaign, Social Post |
| Primary Channel | Dropdown | Yes | Website, Email, Social, Webinar, Sales Enablement |
| Campaign | Short answer | Yes | Use a governed campaign name |
| Funnel Stage | Dropdown | Yes | Awareness, Consideration, Decision, Customer |
| Requested Publish Date | Date | Yes | Must be a valid date |
| Priority | Dropdown | Yes | Standard, High, Urgent |
| Priority Reason | Paragraph | Conditional | Required by policy when Priority is Urgent |
| Design Required | Dropdown | Yes | Yes, No |
| SEO Required | Dropdown | Yes | Yes, No |
| Subject Reviewer Email | Short answer | Yes | Email format |
| Final Approver Email | Short answer | Yes | Email format |
| Source Notes | Paragraph | No | Do not include passwords, regulated personal data, or unapproved confidential material |
| Existing Asset Links | Paragraph | No | One or more governed Drive links |
Use form branching to display Priority Reason when Urgent is selected. Apps Script also validates this rule because form configuration can change and submissions can arrive through copied forms or edited response rows.
If file uploads are added, restrict the form to approved users and review the resulting storage location and ownership. File upload behavior can require sign-in and can vary with Workspace administration. The initial design uses governed Drive links because they are easier to validate and do not move files unexpectedly.
The confirmation message should tell the requester that the submission is not yet approved for publication and that a Record ID will be sent after processing. Include a concise privacy notice explaining how brief data, reviewer identities, and optional AI processing will be handled.
Google Forms provides basic spam resistance when submissions require authenticated organizational accounts. If external submissions are allowed, add a moderation queue and do not automatically notify internal reviewers until the request is confirmed.
Step 3: Create the System of Record
Open the form response spreadsheet and add a bound Apps Script project. The setup function supplied later creates these sheets:
- Content Tracker
- Users
- Config
- Audit Log
- Error Log
- Calendar View
- AI Log
The Content Tracker is the operational table. Do not use merged cells, decorative title rows, or blank header rows. Row 1 contains field names, and every subsequent row contains one content record.
The script creates validation lists for Status, Priority, review fields, Yes/No controls, Automation Status, and AI Status. Protect formula, identifier, logging, and automation-managed columns from ordinary editors. Leave only the fields needed for each role editable.
Record IDs use the format CNT-YYYY-NNNN. The sequence is stored in Apps Script properties and generated under a script lock. The lock prevents two simultaneous submissions from receiving the same identifier.
The duplicate key is a SHA-256 digest of normalized requester email, working title, campaign, and requested publishing date. The digest is used for exact matching. It is not intended as encryption or as a substitute for access control.
Create filtered views for current owner, status, overdue work, exceptions, automation failures, and recently published content. Filtered views are preferable to sorting the shared sheet because they do not disrupt other users.
Step 4: Connect the Tools
| Source | Source field or event | Transformation | Destination | Returned value |
|---|---|---|---|---|
| Google Forms | Form submission | Trim text, validate choices, convert date | Content Tracker | Record ID and processing status |
| Content Tracker | Content type | Rule-based category lookup | Category | Standard reporting category |
| Users sheet | Category, channel, and role | First active exact or wildcard match | Owner fields | Name and email |
| Content Tracker | Record ID and title | Sanitize folder name | Google Drive | Asset Folder Link |
| Content Tracker | Status or review edit | Validate transition and required evidence | Content Tracker and Audit Log | Updated owner, date, and status |
| Apps Script | Assignment or overdue event | Build plain-text operational message | Gmail recipient | Mail send result or logged failure |
| Content Tracker | Active and completed rows | Sort and derive week start and day | Calendar View | Reporting-ready rows |
| Google Sheets | Content Tracker and Calendar View | Looker Studio field typing and calculations | Operational dashboard | Charts, filters, and tables |
| Content Tracker | Approved AI input fields | Redaction, prompt construction, JSON validation | Approved AI API and Google Doc | AI Output Link and review status |
The Google Form and response spreadsheet connection uses Google’s native response destination. Apps Script uses installable spreadsheet triggers. Drive, Document, and Mail services use the identity that authorized the trigger.
The optional AI connection uses an API key in Script Properties and an HTTPS POST request. The source record is not updated with AI suggestions directly. The response is validated, written to a Google Doc, and linked back to the record for review.
Step 5: Build the Core Automation
The form-submit automation runs in this order:
- Trigger: A new response row is added by Google Forms.
- Conditions: Required values are present, choice fields are allowed, dates are valid, emails have a valid format, and Urgent requests include a reason.
- Actions: Standardize values, calculate the duplicate key, generate the Record ID, categorize the request, assign the content owner, calculate the first stage deadline, append the content record, create the Drive folder, and send notifications.
- Fields updated: All brief fields, category, summary, owners, dates, status, folder link, automation status, and source response row.
- Notification: Requester and owner receive the Record ID and next action.
- Exception: Invalid submissions are marked in the response sheet; partial integration failures are marked Retry Required and logged.
Duplicate prevention occurs before record creation. The script obtains a lock, calculates the brief key, and searches the Brief Key column. If the key already exists, the response row is marked Duplicate and the existing Record ID is returned.
Status changes are processed separately through an installable edit trigger:
- The trigger captures the requested new status and restores the old status temporarily.
- The script confirms that the transition is allowed from the current stage.
- The complete row is checked for required evidence.
- If the gate fails, the previous status remains and the Error Message identifies the missing fields.
- If the gate passes, the new status is written.
- The current owner is changed to the appropriate requester, content owner, reviewer, designer, approver, or marketing operations user.
- Stage Entered Date, Stage Due Date, Last Updated, and Last Automation Run are updated.
- An audit event is appended.
- The new owner receives a notification.
- The Calendar View is rebuilt.
Bulk pasting status values is prohibited because an edit event may not represent each row as an independent workflow decision. Authorized users should update records individually or use a separately tested bulk transition function.
Step 6: Add Approvals, Reminders, and Escalations
Subject review and final approval use controlled fields rather than interpreting chat messages.
- Subject approval trigger: Subject Review Status changes to Approved.
- Condition: The current workflow status is Subject Review and a Draft Link exists.
- Action: Record Subject Review Date and advance to Design and SEO.
- Return action: Changes Requested returns the record to Drafting and assigns the content owner.
- Final approval trigger: Approval Status changes to Approved, Changes Requested, or Rejected.
- Approved condition: Required design, SEO, link, alt-text, and proofreading checks are complete.
- Approved action: Record Approval Date and advance to Scheduled.
- Changes Requested action: Return to Drafting and preserve review feedback.
- Rejected action: Move to Cancelled and retain approval evidence.
Final approval is a human action. The automation only validates the evidence and performs the resulting deterministic status update.
The daily monitor compares Stage Due Date with the current date. It sends one reminder per day after the due date. After the configured escalation period, marketing operations is copied. The Users sheet includes a Backup Email field that can be used when the current owner is unavailable.
Delegation is handled by updating the relevant reviewer or approver email and recording a Reassignment event in the Audit Log. Marketing operations confirms the delegate’s authority before making the change.
Step 7: Add Documents and File Management
The root Drive folder contains one folder per content record:
Marketing Content Operations
CNT-2026-0043 - Reducing Approval Delays in Field Service Teams
CNT-2026-0044 - Customer Onboarding Webinar
CNT-2026-0045 - Product Release Email
The folder name starts with Record ID because titles can change. Apps Script removes characters that are unsuitable for a folder name and limits the title portion to a manageable length.
Inside each record folder, the team uses a consistent structure when the number of files warrants it:
01 Brief and Research02 Drafts03 Design04 Review Evidence05 Published Assets
The supplied script creates the record-level folder. Subfolders can be added to the folder creation function if the team consistently needs them. Avoid creating empty subfolders that add navigation without operational value.
Draft files use names such as CNT-2026-0043 Draft v1. Google Docs version history remains the primary version-control mechanism. Teams should not replace a governed file with an unrelated file at the same link.
The Drive folder inherits access from the root folder. Shared links should not be public. Sensitive customer evidence, licensed media, and unreleased product information require narrower access where appropriate.
If folder creation fails, the record remains in the Content Tracker with Automation Status set to Retry Required. The daily recovery function searches by the unique folder name before creating anything, reducing the chance of duplicate folders.
Step 8: Add Reporting and Operational Views
Connect Looker Studio to the implementation spreadsheet. Create one data source for Content Tracker and one for Calendar View. Configure the first row as headers and confirm that date and timestamp columns are recognized correctly.
Create these operational pages:
- Work Queue: Active items grouped by status and current owner.
- Overdue Work: Items with Stage Due Date before today, excluding Published and Cancelled.
- Editorial Calendar: Planned items sorted by Requested Publish Date with week and day fields.
- Exceptions: Needs Information, Blocked, Retry Required, Manual Review, and rejected records.
- Throughput: Submissions and published items by month, type, channel, and campaign.
- Cycle Time: Days from Created Date to Published Date for completed records.
- Automation Health: Records by Automation Status, Retry Count, and latest automation run.
Useful Looker Studio calculated fields include:
Overdue Flag
CASE
WHEN Status NOT IN ("Published", "Cancelled")
AND Stage Due Date < CURRENT_DATE()
THEN "Overdue"
ELSE "On time"
END
Cycle Days
DATE_DIFF(Published Date, Created Date)
For the calendar page, use Calendar View as the source. Add a date-range control, a detail table sorted by Publish Date, and a pivot table with Week Start as rows and Day Name as columns. The metric can be Record Count, with title and status available in the detail table. This produces a practical weekly editorial calendar without requiring users to edit a visual calendar separately.
Apps Script rebuilds Calendar View after controlled status changes and during the daily monitor. Looker Studio refresh timing depends on the connector and report configuration, so users should understand that the report is operationally current but not a transactional interface.
The marketing operations manager owns dashboard definitions. Alert thresholds, such as more than five overdue items or any Manual Review records, should be agreed with leadership rather than chosen solely for visual effect.
Step 9: Add Security and Governance Controls
- Limit spreadsheet editing to the marketing team and approved reviewers.
- Protect Record ID, Brief Key, timestamps, calculated ownership fields, automation status, and logs.
- Use role-based Drive groups where available rather than adding users individually to many folders.
- Restrict public and organization-wide link sharing for draft assets.
- Store the AI API key in Apps Script project properties, not Config, source code, or a document.
- Review trigger ownership after role changes and remove former employees promptly.
- Retain the Audit Log and Error Log according to the company’s marketing, privacy, and legal policies.
- Back up the spreadsheet and Apps Script source before material releases.
- Do not send credentials, regulated health information, payment data, contract secrets, or unapproved customer personal data to the AI service.
- Require final human approval before scheduling or publication.
- Record policy exceptions and manual overrides instead of silently bypassing gates.
Apps Script executions, spreadsheet version history, Drive activity, Audit Log entries, and approval fields together provide operational evidence. They do not automatically satisfy a specific regulatory framework. Formal compliance requirements need a separate assessment.
Step 10: Deploy and Test
- Build the form and spreadsheet in the test environment.
- Paste the supplied script into the bound Apps Script project.
- Run
setupWorkbookand authorize the requested Google services. - Replace Config placeholders, populate Users with test identities, and run
runConfigurationCheck. - Run
installTriggersto create the form-submit, edit, and daily time triggers. - Submit at least ten sample records covering all content types, priorities, and design or SEO combinations.
- Complete every forward, backward, rejected, blocked, and recovery path.
- Verify Drive folders, email messages, audit rows, error rows, and Calendar View output.
- Connect a test Looker Studio report and confirm date types, filters, counts, and overdue calculations.
- Conduct user acceptance testing with a requester, content owner, reviewer, approver, and marketing operations administrator.
- Pilot the workflow with one campaign for two weeks or one complete content cycle.
- Document defects and resolve them before copying configuration into production.
- Activate the production triggers only after the production folder, users, permissions, and reporting sources are confirmed.
- Keep a rollback copy of the prior planning spreadsheet and a documented method for pausing triggers.
- Send launch instructions that explain statuses, ownership, review fields, folder use, failure reporting, and where support requests should go.
Code and Configuration
The implementation requires code because native Google Forms and Sheets features do not provide the required duplicate detection, unique identifiers, status gates, owner changes, folder creation, audit logging, reminders, retries, or optional structured AI request.
The following complete Apps Script is placed in the spreadsheet that receives Google Form responses. Open the spreadsheet, open its Apps Script editor, replace the default editor content, and paste the script into a single Code.gs file. Additional files can be created later for maintainability without changing function names.
const APP = Object.freeze({
sheets: {
content: "Content Tracker",
users: "Users",
config: "Config",
audit: "Audit Log",
errors: "Error Log",
calendar: "Calendar View",
aiLog: "AI Log"
},
headers: {
content: [
"Record ID", "Brief Key", "Created Date", "Last Updated",
"Requester Name", "Requester Email", "Working Title",
"Business Objective", "Audience", "Content Type", "Channel",
"Campaign", "Funnel Stage", "Category", "Priority",
"Priority Reason", "Requested Publish Date", "Published Date",
"Stage Due Date", "Stage Entered Date", "Status",
"Content Owner Name", "Content Owner Email",
"Owner Name", "Owner Email", "Subject Reviewer Email",
"Subject Review Status", "Subject Review Date",
"Final Approver Email", "Approval Status", "Approval Date",
"Design Required", "SEO Required", "Brief Summary",
"Source Notes", "Source Asset Links", "Draft Link", "Design Link",
"Asset Folder Link", "SEO Title", "Meta Description",
"Primary Keyword", "Links Checked", "Alt Text Complete",
"Proofread Complete", "Scheduled Date", "Published URL",
"External CMS ID", "Review Feedback", "Exception Type",
"Automation Status", "Last Automation Run",
"Last Reminder Sent", "Last Escalation Sent", "Retry Count",
"Error Message", "AI Allowed", "AI Status", "AI Confidence",
"AI Output Link", "AI Last Run", "Notes", "Form Response Row"
],
users: [
"Name", "Email", "Role", "Categories", "Channels",
"Active", "Backup Email"
],
config: ["Key", "Value", "Description"],
audit: ["Timestamp", "Record ID", "Event", "Actor", "Details"],
errors: [
"Timestamp", "Record ID", "Step", "Message", "Stack",
"Context", "Resolved", "Resolved By", "Resolved Date"
],
calendar: [
"Publish Date", "Week Start", "Day Name", "Record ID",
"Working Title", "Content Type", "Channel", "Campaign",
"Status", "Owner Name", "Approval Status",
"Asset Folder Link", "Published URL"
],
aiLog: [
"Timestamp", "Record ID", "Model", "Status", "Confidence",
"Input Characters", "Output Characters", "Input Tokens",
"Output Tokens", "Request ID", "Error"
]
},
statuses: [
"Needs Information", "Brief Ready", "Drafting", "Subject Review",
"Design and SEO", "Final Approval", "Scheduled", "Published",
"Blocked", "Cancelled"
],
contentTypes: [
"Blog Article", "Case Study", "Product Page", "Webinar",
"Email Campaign", "Social Post"
],
channels: [
"Website", "Email", "Social", "Webinar", "Sales Enablement"
],
funnelStages: ["Awareness", "Consideration", "Decision", "Customer"],
priorities: ["Standard", "High", "Urgent"]
});
function onOpen() {
SpreadsheetApp.getUi()
.createMenu("Content Operations")
.addItem("Set up workbook", "setupWorkbook")
.addItem("Install or replace triggers", "installTriggers")
.addItem("Run configuration check", "runConfigurationCheck")
.addItem("Refresh calendar view", "refreshCalendarView")
.addItem("Run daily monitor now", "dailyWorkflowMonitor")
.addSeparator()
.addItem("Generate AI assist for selected row", "generateAiAssistForSelectedRow")
.addToUi();
}
function setupWorkbook() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
PropertiesService.getScriptProperties()
.setProperty("CONTENT_OPS_SPREADSHEET_ID", ss.getId());
ensureSheet_(ss, APP.sheets.content, APP.headers.content);
ensureSheet_(ss, APP.sheets.users, APP.headers.users);
ensureSheet_(ss, APP.sheets.config, APP.headers.config);
ensureSheet_(ss, APP.sheets.audit, APP.headers.audit);
ensureSheet_(ss, APP.sheets.errors, APP.headers.errors);
ensureSheet_(ss, APP.sheets.calendar, APP.headers.calendar);
ensureSheet_(ss, APP.sheets.aiLog, APP.headers.aiLog);
seedConfig_();
seedUsers_();
applyValidations_();
refreshCalendarView();
SpreadsheetApp.getUi().alert(
"Workbook structure created. Replace Config and Users placeholders, " +
"then run the configuration check and install triggers."
);
}
function installTriggers() {
const ss = getSpreadsheet_();
const managedHandlers = [
"handleFormSubmit",
"handleContentEdit",
"dailyWorkflowMonitor"
];
ScriptApp.getProjectTriggers().forEach(function(trigger) {
if (managedHandlers.indexOf(trigger.getHandlerFunction()) !== -1) {
ScriptApp.deleteTrigger(trigger);
}
});
ScriptApp.newTrigger("handleFormSubmit")
.forSpreadsheet(ss)
.onFormSubmit()
.create();
ScriptApp.newTrigger("handleContentEdit")
.forSpreadsheet(ss)
.onEdit()
.create();
ScriptApp.newTrigger("dailyWorkflowMonitor")
.timeBased()
.everyDays(1)
.atHour(7)
.create();
SpreadsheetApp.getUi().alert(
"Installed form-submit, edit, and daily monitoring triggers."
);
}
function runConfigurationCheck() {
const issues = [];
const config = getConfig_();
const ss = getSpreadsheet_();
Object.keys(APP.sheets).forEach(function(key) {
if (!ss.getSheetByName(APP.sheets[key])) {
issues.push("Missing sheet: " + APP.sheets[key]);
}
});
if (!config.ROOT_FOLDER_ID ||
config.ROOT_FOLDER_ID.indexOf("YOUR_") === 0) {
issues.push("Replace ROOT_FOLDER_ID in Config.");
} else {
try {
DriveApp.getFolderById(config.ROOT_FOLDER_ID).getName();
} catch (error) {
issues.push("ROOT_FOLDER_ID is inaccessible: " + error.message);
}
}
if (!isEmail_(config.MARKETING_OPS_EMAIL)) {
issues.push("Set a valid MARKETING_OPS_EMAIL in Config.");
}
const owner = resolveUser_("Content Owner", "*", "*");
if (!owner.email) {
issues.push("Add at least one active Content Owner in Users.");
}
if (String(config.AI_ENABLED).toUpperCase() === "TRUE") {
if (!config.AI_ENDPOINT ||
!config.AI_MODEL ||
config.AI_MODEL.indexOf("YOUR_") === 0) {
issues.push("AI is enabled but endpoint or approved model is missing.");
}
if (!PropertiesService.getScriptProperties()
.getProperty("OPENAI_API_KEY")) {
issues.push("AI is enabled but OPENAI_API_KEY is not in Script Properties.");
}
}
SpreadsheetApp.getUi().alert(
issues.length === 0
? "Configuration check passed."
: "Configuration issues:\n\n" + issues.join("\n")
);
}
function handleFormSubmit(e) {
if (!e || !e.range) {
throw new Error("This function requires a spreadsheet form-submit event.");
}
try {
processSubmission_(e);
} catch (error) {
const context = {
sheet: e.range.getSheet().getName(),
row: e.range.getRow()
};
logError_("", "FORM_SUBMISSION", error, context);
updateResponseProcessing_(e, "ERROR", "", error.message);
try {
const config = getConfig_();
sendMail_(
[config.MARKETING_OPS_EMAIL],
"Content intake automation failed",
"A form response failed on row " + e.range.getRow() +
". Review the Error Log.\n\n" + error.message
);
} catch (notificationError) {
console.error(notificationError);
}
throw error;
}
}
function processSubmission_(e) {
const submitted = readSubmission_(e);
validateSubmission_(submitted);
const briefKey = buildBriefKey_(submitted);
const contentSheet = getSpreadsheet_().getSheetByName(APP.sheets.content);
const lock = LockService.getScriptLock();
let recordId;
let recordRow;
lock.waitLock(30000);
try {
const duplicateRow = findRowByValue_(
contentSheet,
"Brief Key",
briefKey
);
if (duplicateRow) {
const existing = rowObject_(contentSheet, duplicateRow);
updateResponseProcessing_(
e,
"DUPLICATE",
existing["Record ID"],
"Existing record returned; no new record created."
);
logAudit_(
existing["Record ID"],
"DUPLICATE_IGNORED",
submitted.requesterEmail,
"Form response row " + e.range.getRow()
);
return;
}
recordId = nextRecordId_(contentSheet);
const category = categorize_(submitted.contentType);
const contentOwner = resolveUser_(
"Content Owner",
category,
submitted.channel
);
const now = new Date();
const stageDue = calculateStageDue_(
"Brief Ready",
submitted.publishDate
);
const record = {
"Record ID": recordId,
"Brief Key": briefKey,
"Created Date": now,
"Last Updated": now,
"Requester Name": submitted.requesterName,
"Requester Email": submitted.requesterEmail,
"Working Title": submitted.workingTitle,
"Business Objective": submitted.businessObjective,
"Audience": submitted.audience,
"Content Type": submitted.contentType,
"Channel": submitted.channel,
"Campaign": submitted.campaign,
"Funnel Stage": submitted.funnelStage,
"Category": category,
"Priority": submitted.priority,
"Priority Reason": submitted.priorityReason,
"Requested Publish Date": submitted.publishDate,
"Published Date": "",
"Stage Due Date": stageDue,
"Stage Entered Date": now,
"Status": "Brief Ready",
"Content Owner Name": contentOwner.name,
"Content Owner Email": contentOwner.email,
"Owner Name": contentOwner.name,
"Owner Email": contentOwner.email,
"Subject Reviewer Email": submitted.subjectReviewerEmail,
"Subject Review Status": "Pending",
"Subject Review Date": "",
"Final Approver Email": submitted.finalApproverEmail,
"Approval Status": "Pending",
"Approval Date": "",
"Design Required": submitted.designRequired,
"SEO Required": submitted.seoRequired,
"Brief Summary": buildBriefSummary_(submitted, category),
"Source Notes": submitted.sourceNotes,
"Source Asset Links": submitted.existingAssetLinks,
"Draft Link": "",
"Design Link": "",
"Asset Folder Link": "",
"SEO Title": "",
"Meta Description": "",
"Primary Keyword": "",
"Links Checked": "No",
"Alt Text Complete": "No",
"Proofread Complete": "No",
"Scheduled Date": "",
"Published URL": "",
"External CMS ID": "",
"Review Feedback": "",
"Exception Type": "",
"Automation Status": "Processing",
"Last Automation Run": now,
"Last Reminder Sent": "",
"Last Escalation Sent": "",
"Retry Count": 0,
"Error Message": "",
"AI Allowed": "No",
"AI Status": "Not Requested",
"AI Confidence": "",
"AI Output Link": "",
"AI Last Run": "",
"Notes": "",
"Form Response Row": e.range.getRow()
};
recordRow = appendObjectRow_(contentSheet, record);
} finally {
lock.releaseLock();
}
const integrationErrors = [];
try {
const folder = createOrGetAssetFolder_(
recordId,
submitted.workingTitle
);
setField_(contentSheet, recordRow, "Asset Folder Link", folder.getUrl());
} catch (error) {
integrationErrors.push("ASSET_FOLDER: " + error.message);
logError_(recordId, "ASSET_FOLDER", error, { row: recordRow });
}
try {
const current = rowObject_(contentSheet, recordRow);
sendCreationNotice_(current);
} catch (error) {
integrationErrors.push("NOTIFICATION: " + error.message);
logError_(recordId, "CREATION_NOTIFICATION", error, { row: recordRow });
}
const now = new Date();
setField_(contentSheet, recordRow, "Last Automation Run", now);
setField_(
contentSheet,
recordRow,
"Automation Status",
integrationErrors.length ? "Retry Required" : "Complete"
);
setField_(
contentSheet,
recordRow,
"Exception Type",
integrationErrors.length ? "INTEGRATION_FAILURE" : ""
);
setField_(
contentSheet,
recordRow,
"Error Message",
integrationErrors.join(" | ")
);
updateResponseProcessing_(
e,
integrationErrors.length ? "PROCESSED_WITH_WARNING" : "PROCESSED",
recordId,
integrationErrors.join(" | ")
);
logAudit_(
recordId,
"RECORD_CREATED",
submitted.requesterEmail,
JSON.stringify({
status: "Brief Ready",
owner: rowObject_(contentSheet, recordRow)["Owner Email"],
responseRow: e.range.getRow()
})
);
refreshCalendarView();
}
function readSubmission_(e) {
return {
requesterName: clean_(formValue_(e, "Requester Name")),
requesterEmail: clean_(formValue_(e, "Requester Email")).toLowerCase(),
workingTitle: clean_(formValue_(e, "Working Title")),
businessObjective: clean_(formValue_(e, "Business Objective")),
audience: clean_(formValue_(e, "Primary Audience")),
contentType: clean_(formValue_(e, "Content Type")),
channel: clean_(formValue_(e, "Primary Channel")),
campaign: clean_(formValue_(e, "Campaign")),
funnelStage: clean_(formValue_(e, "Funnel Stage")),
publishDate: asDate_(formValue_(e, "Requested Publish Date")),
priority: clean_(formValue_(e, "Priority")),
priorityReason: clean_(formValue_(e, "Priority Reason")),
designRequired: clean_(formValue_(e, "Design Required")),
seoRequired: clean_(formValue_(e, "SEO Required")),
subjectReviewerEmail: clean_(
formValue_(e, "Subject Reviewer Email")
).toLowerCase(),
finalApproverEmail: clean_(
formValue_(e, "Final Approver Email")
).toLowerCase(),
sourceNotes: clean_(formValue_(e, "Source Notes")),
existingAssetLinks: clean_(formValue_(e, "Existing Asset Links"))
};
}
function validateSubmission_(submitted) {
const required = {
"Requester Name": submitted.requesterName,
"Requester Email": submitted.requesterEmail,
"Working Title": submitted.workingTitle,
"Business Objective": submitted.businessObjective,
"Primary Audience": submitted.audience,
"Content Type": submitted.contentType,
"Primary Channel": submitted.channel,
"Campaign": submitted.campaign,
"Funnel Stage": submitted.funnelStage,
"Requested Publish Date": submitted.publishDate,
"Priority": submitted.priority,
"Design Required": submitted.designRequired,
"SEO Required": submitted.seoRequired,
"Subject Reviewer Email": submitted.subjectReviewerEmail,
"Final Approver Email": submitted.finalApproverEmail
};
const missing = Object.keys(required).filter(function(key) {
const value = required[key];
return value === "" || value === null ||
(value instanceof Date && isNaN(value.getTime()));
});
if (missing.length) {
throw new Error("Missing or invalid required fields: " + missing.join(", "));
}
if (!isEmail_(submitted.requesterEmail) ||
!isEmail_(submitted.subjectReviewerEmail) ||
!isEmail_(submitted.finalApproverEmail)) {
throw new Error("One or more email addresses are invalid.");
}
validateChoice_(submitted.contentType, APP.contentTypes, "Content Type");
validateChoice_(submitted.channel, APP.channels, "Primary Channel");
validateChoice_(submitted.funnelStage, APP.funnelStages, "Funnel Stage");
validateChoice_(submitted.priority, APP.priorities, "Priority");
validateChoice_(submitted.designRequired, ["Yes", "No"], "Design Required");
validateChoice_(submitted.seoRequired, ["Yes", "No"], "SEO Required");
if (submitted.priority === "Urgent" && !submitted.priorityReason) {
throw new Error("Priority Reason is required for Urgent requests.");
}
}
function handleContentEdit(e) {
if (!e || !e.range || e.range.getNumRows() !== 1 ||
e.range.getNumColumns() !== 1) {
return;
}
const sheet = e.range.getSheet();
if (sheet.getName() !== APP.sheets.content ||
e.range.getRow() === 1) {
return;
}
const header = sheet.getRange(1, e.range.getColumn()).getValue();
const row = e.range.getRow();
const actor = eventActor_(e);
const now = new Date();
setField_(sheet, row, "Last Updated", now);
if (header === "Status") {
const requestedStatus = clean_(e.value);
const oldStatus = clean_(e.oldValue);
e.range.setValue(oldStatus);
try {
changeStatus_(sheet, row, requestedStatus, actor);
} catch (error) {
markRowException_(
sheet,
row,
"STATUS_GATE_FAILED",
error.message,
"Manual Review"
);
logError_(
rowObject_(sheet, row)["Record ID"],
"STATUS_CHANGE",
error,
{ oldStatus: oldStatus, requestedStatus: requestedStatus }
);
}
return;
}
if (header === "Subject Review Status") {
handleSubjectReviewEdit_(sheet, row, clean_(e.value), actor);
return;
}
if (header === "Approval Status") {
handleApprovalEdit_(sheet, row, clean_(e.value), actor);
return;
}
const auditedFields = [
"Owner Email", "Subject Reviewer Email", "Final Approver Email",
"Draft Link", "Design Link", "Published URL", "External CMS ID",
"Links Checked", "Alt Text Complete", "Proofread Complete",
"AI Allowed", "AI Status"
];
if (auditedFields.indexOf(header) !== -1) {
const record = rowObject_(sheet, row);
logAudit_(
record["Record ID"],
"FIELD_UPDATED",
actor,
JSON.stringify({ field: header })
);
}
}
function handleSubjectReviewEdit_(sheet, row, value, actor) {
if (["Pending", "Approved", "Changes Requested"].indexOf(value) === -1) {
markRowException_(
sheet,
row,
"INVALID_REVIEW_VALUE",
"Invalid Subject Review Status.",
"Manual Review"
);
return;
}
const record = rowObject_(sheet, row);
logAudit_(
record["Record ID"],
"SUBJECT_REVIEW_UPDATED",
actor,
value
);
if (value === "Approved") {
setField_(sheet, row, "Subject Review Date", new Date());
try {
changeStatus_(sheet, row, "Design and SEO", actor);
} catch (error) {
markRowException_(
sheet,
row,
"SUBJECT_REVIEW_GATE",
error.message,
"Manual Review"
);
}
}
if (value === "Changes Requested") {
changeStatus_(sheet, row, "Drafting", actor, true);
}
}
function handleApprovalEdit_(sheet, row, value, actor) {
const allowed = [
"Pending", "Approved", "Changes Requested", "Rejected"
];
if (allowed.indexOf(value) === -1) {
markRowException_(
sheet,
row,
"INVALID_APPROVAL_VALUE",
"Invalid Approval Status.",
"Manual Review"
);
return;
}
const record = rowObject_(sheet, row);
logAudit_(
record["Record ID"],
"FINAL_APPROVAL_UPDATED",
actor,
value
);
if (value === "Approved") {
setField_(sheet, row, "Approval Date", new Date());
try {
changeStatus_(sheet, row, "Scheduled", actor);
} catch (error) {
setField_(
sheet,
row,
"Owner Name",
record["Content Owner Name"]
);
setField_(
sheet,
row,
"Owner Email",
record["Content Owner Email"]
);
markRowException_(
sheet,
row,
"PUBLISHING_CHECKS_INCOMPLETE",
error.message,
"Manual Review"
);
}
}
if (value === "Changes Requested") {
changeStatus_(sheet, row, "Drafting", actor, true);
}
if (value === "Rejected") {
changeStatus_(sheet, row, "Cancelled", actor, true);
}
}
function changeStatus_(sheet, row, newStatus, actor, bypassTransition) {
const record = rowObject_(sheet, row);
const oldStatus = clean_(record["Status"]);
if (APP.statuses.indexOf(newStatus) === -1) {
throw new Error("Invalid target status: " + newStatus);
}
if (newStatus === oldStatus) {
return;
}
if (!bypassTransition) {
validateTransition_(oldStatus, newStatus);
}
validateStatusGate_(record, newStatus);
const now = new Date();
setField_(sheet, row, "Status", newStatus);
setField_(sheet, row, "Stage Entered Date", now);
setField_(sheet, row, "Last Updated", now);
setField_(sheet, row, "Last Automation Run", now);
setField_(
sheet,
row,
"Stage Due Date",
calculateStageDue_(newStatus, record["Requested Publish Date"])
);
setField_(sheet, row, "Exception Type", "");
setField_(sheet, row, "Error Message", "");
setField_(sheet, row, "Automation Status", "Complete");
if (newStatus === "Final Approval") {
setField_(sheet, row, "Approval Status", "Pending");
}
if (newStatus === "Scheduled" && !record["Scheduled Date"]) {
setField_(sheet, row, "Scheduled Date", now);
}
if (newStatus === "Published") {
setField_(sheet, row, "Published Date", now);
}
const owner = ownerForStatus_(record, newStatus);
setField_(sheet, row, "Owner Name", owner.name);
setField_(sheet, row, "Owner Email", owner.email);
logAudit_(
record["Record ID"],
"STATUS_CHANGED",
actor,
JSON.stringify({ from: oldStatus, to: newStatus, owner: owner.email })
);
try {
const updated = rowObject_(sheet, row);
sendStatusNotice_(updated, oldStatus, newStatus);
} catch (error) {
markRowException_(
sheet,
row,
"NOTIFICATION_FAILURE",
"NOTIFICATION: " + error.message,
"Retry Required"
);
logError_(
record["Record ID"],
"STATUS_NOTIFICATION",
error,
{ from: oldStatus, to: newStatus }
);
}
refreshCalendarView();
}
function validateTransition_(fromStatus, toStatus) {
const transitions = {
"Needs Information": ["Brief Ready", "Drafting", "Blocked", "Cancelled"],
"Brief Ready": ["Drafting", "Needs Information", "Blocked", "Cancelled"],
"Drafting": ["Subject Review", "Needs Information", "Blocked", "Cancelled"],
"Subject Review": [
"Drafting", "Design and SEO", "Blocked", "Cancelled"
],
"Design and SEO": [
"Final Approval", "Drafting", "Blocked", "Cancelled"
],
"Final Approval": ["Scheduled", "Drafting", "Blocked", "Cancelled"],
"Scheduled": ["Published", "Blocked", "Cancelled"],
"Published": [],
"Blocked": [
"Brief Ready", "Drafting", "Subject Review", "Design and SEO",
"Final Approval", "Scheduled", "Cancelled"
],
"Cancelled": []
};
if (!transitions[fromStatus] ||
transitions[fromStatus].indexOf(toStatus) === -1) {
throw new Error(
"Transition from " + fromStatus + " to " + toStatus +
" is not allowed."
);
}
}
function validateStatusGate_(record, targetStatus) {
const missing = [];
if (targetStatus === "Brief Ready") {
["Business Objective", "Audience", "Content Type", "Channel"].forEach(
function(field) {
if (!clean_(record[field])) {
missing.push(field);
}
}
);
}
if (targetStatus === "Subject Review" &&
!clean_(record["Draft Link"])) {
missing.push("Draft Link");
}
if (targetStatus === "Design and SEO" &&
record["Subject Review Status"] !== "Approved") {
missing.push("Subject Review Status = Approved");
}
if (targetStatus === "Final Approval") {
if (!clean_(record["Draft Link"])) {
missing.push("Draft Link");
}
if (record["Subject Review Status"] !== "Approved") {
missing.push("Subject Review Status = Approved");
}
if (record["Design Required"] === "Yes" &&
!clean_(record["Design Link"])) {
missing.push("Design Link");
}
}
if (targetStatus === "Scheduled") {
if (record["Approval Status"] !== "Approved") {
missing.push("Approval Status = Approved");
}
missing.push.apply(missing, publishingCheckFailures_(record));
}
if (targetStatus === "Published" &&
!clean_(record["Published URL"])) {
missing.push("Published URL");
}
if (missing.length) {
throw new Error("Required gate items: " + missing.join(", "));
}
}
function publishingCheckFailures_(record) {
const failures = [];
if (record["Links Checked"] !== "Yes") {
failures.push("Links Checked = Yes");
}
if (record["Proofread Complete"] !== "Yes") {
failures.push("Proofread Complete = Yes");
}
if (record["Design Required"] === "Yes") {
if (!clean_(record["Design Link"])) {
failures.push("Design Link");
}
if (record["Alt Text Complete"] !== "Yes") {
failures.push("Alt Text Complete = Yes");
}
}
if (record["SEO Required"] === "Yes") {
const seoTitle = clean_(record["SEO Title"]);
const meta = clean_(record["Meta Description"]);
if (!seoTitle) {
failures.push("SEO Title");
} else if (seoTitle.length > 65) {
failures.push("SEO Title must be 65 characters or fewer");
}
if (!meta) {
failures.push("Meta Description");
} else if (meta.length > 160) {
failures.push("Meta Description must be 160 characters or fewer");
}
if (!clean_(record["Primary Keyword"])) {
failures.push("Primary Keyword");
}
}
return failures;
}
function ownerForStatus_(record, status) {
if (status === "Needs Information") {
return {
name: record["Requester Name"],
email: record["Requester Email"]
};
}
if (["Brief Ready", "Drafting"].indexOf(status) !== -1) {
return {
name: record["Content Owner Name"],
email: record["Content Owner Email"]
};
}
if (status === "Subject Review") {
return {
name: "Subject Reviewer",
email: record["Subject Reviewer Email"]
};
}
if (status === "Design and SEO") {
return resolveUser_("Designer", record["Category"], record["Channel"]);
}
if (status === "Final Approval") {
return {
name: "Final Approver",
email: record["Final Approver Email"]
};
}
if (["Scheduled", "Published", "Cancelled"].indexOf(status) !== -1) {
return resolveUser_(
"Marketing Operations",
record["Category"],
record["Channel"]
);
}
return {
name: record["Owner Name"],
email: record["Owner Email"]
};
}
function dailyWorkflowMonitor() {
const ss = getSpreadsheet_();
const sheet = ss.getSheetByName(APP.sheets.content);
const config = getConfig_();
const values = sheet.getDataRange().getValues();
if (values.length < 2) {
refreshCalendarView();
return;
}
const headers = values[0];
const today = dateOnly_(new Date());
const escalationDays = Number(config.ESCALATION_DAYS || 2);
for (let i = 1; i < values.length; i++) {
const record = objectFromValues_(headers, values[i]);
const status = record["Status"];
if (["Published", "Cancelled"].indexOf(status) !== -1) {
continue;
}
const due = asDate_(record["Stage Due Date"]);
if (!due || isNaN(due.getTime()) || dateOnly_(due) >= today) {
continue;
}
const row = i + 1;
const overdueDays = daysBetween_(dateOnly_(due), today);
const reminderSent = asDate_(record["Last Reminder Sent"]);
const reminderAlreadySent = reminderSent &&
sameDate_(reminderSent, today);
if (!reminderAlreadySent) {
const recipients = [record["Owner Email"]];
let escalated = false;
if (overdueDays >= escalationDays) {
recipients.push(config.MARKETING_OPS_EMAIL);
escalated = true;
}
try {
sendMail_(
recipients,
"Overdue content action: " + record["Record ID"],
"Record: " + record["Record ID"] +
"\nTitle: " + record["Working Title"] +
"\nStatus: " + status +
"\nOwner: " + record["Owner Email"] +
"\nStage due date: " + formatDate_(due) +
"\nDays overdue: " + overdueDays +
"\n\nOpen the Content Tracker and complete or reassign the action."
);
setField_(sheet, row, "Last Reminder Sent", new Date());
if (escalated) {
setField_(sheet, row, "Last Escalation Sent", new Date());
}
logAudit_(
record["Record ID"],
escalated ? "OVERDUE_ESCALATION" : "OVERDUE_REMINDER",
"AUTOMATION",
"Days overdue: " + overdueDays
);
} catch (error) {
markRowException_(
sheet,
row,
"NOTIFICATION_FAILURE",
"NOTIFICATION: " + error.message,
"Retry Required"
);
logError_(
record["Record ID"],
"OVERDUE_NOTIFICATION",
error,
{ row: row }
);
}
}
}
retryFailedRecords_();
refreshCalendarView();
}
function retryFailedRecords_() {
const sheet = getSpreadsheet_().getSheetByName(APP.sheets.content);
const values = sheet.getDataRange().getValues();
if (values.length < 2) {
return;
}
const headers = values[0];
for (let i = 1; i < values.length; i++) {
const record = objectFromValues_(headers, values[i]);
if (record["Automation Status"] !== "Retry Required") {
continue;
}
const row = i + 1;
const retryCount = Number(record["Retry Count"] || 0);
if (retryCount >= 3) {
setField_(sheet, row, "Automation Status", "Manual Review");
continue;
}
const errors = [];
const priorError = clean_(record["Error Message"]);
if (!clean_(record["Asset Folder Link"]) ||
priorError.indexOf("ASSET_FOLDER") !== -1) {
try {
const folder = createOrGetAssetFolder_(
record["Record ID"],
record["Working Title"]
);
setField_(sheet, row, "Asset Folder Link", folder.getUrl());
} catch (error) {
errors.push("ASSET_FOLDER: " + error.message);
}
}
if (priorError.indexOf("NOTIFICATION") !== -1) {
try {
sendMail_(
[record["Owner Email"]],
"Recovered content workflow notification: " +
record["Record ID"],
"The automation recovered a previously failed action.\n\n" +
"Record: " + record["Record ID"] +
"\nTitle: " + record["Working Title"] +
"\nStatus: " + record["Status"]
);
} catch (error) {
errors.push("NOTIFICATION: " + error.message);
}
}
setField_(sheet, row, "Retry Count", retryCount + 1);
setField_(sheet, row, "Last Automation Run", new Date());
if (errors.length) {
setField_(sheet, row, "Error Message", errors.join(" | "));
setField_(
sheet,
row,
"Automation Status",
retryCount + 1 >= 3 ? "Manual Review" : "Retry Required"
);
} else {
setField_(sheet, row, "Error Message", "");
setField_(sheet, row, "Exception Type", "");
setField_(sheet, row, "Automation Status", "Complete");
logAudit_(
record["Record ID"],
"AUTOMATION_RECOVERED",
"AUTOMATION",
"Retry " + (retryCount + 1)
);
}
}
}
function refreshCalendarView() {
const ss = getSpreadsheet_();
const contentSheet = ss.getSheetByName(APP.sheets.content);
const calendarSheet = ss.getSheetByName(APP.sheets.calendar);
if (!contentSheet || !calendarSheet) {
return;
}
const values = contentSheet.getDataRange().getValues();
const output = [APP.headers.calendar];
if (values.length > 1) {
const headers = values[0];
for (let i = 1; i < values.length; i++) {
const record = objectFromValues_(headers, values[i]);
const publishDate = asDate_(record["Requested Publish Date"]);
if (!publishDate || isNaN(publishDate.getTime())) {
continue;
}
const weekStart = startOfWeek_(publishDate);
output.push([
publishDate,
weekStart,
Utilities.formatDate(
publishDate,
getSpreadsheet_().getSpreadsheetTimeZone(),
"EEEE"
),
record["Record ID"],
record["Working Title"],
record["Content Type"],
record["Channel"],
record["Campaign"],
record["Status"],
record["Owner Name"],
record["Approval Status"],
record["Asset Folder Link"],
record["Published URL"]
]);
}
output.splice(
1,
output.length - 1,
...output.slice(1).sort(function(a, b) {
return a[0].getTime() - b[0].getTime();
})
);
}
calendarSheet.clearContents();
calendarSheet.getRange(1, 1, output.length, output[0].length)
.setValues(output);
calendarSheet.setFrozenRows(1);
if (output.length > 1) {
calendarSheet.getRange(2, 1, output.length - 1, 2)
.setNumberFormat("yyyy-mm-dd");
}
}
function generateAiAssistForSelectedRow() {
const sheet = SpreadsheetApp.getActiveSheet();
const row = sheet.getActiveRange().getRow();
if (sheet.getName() !== APP.sheets.content || row < 2) {
SpreadsheetApp.getUi().alert(
"Select one content record in the Content Tracker."
);
return;
}
runAiAssistForRow_(sheet, row);
}
function runAiAssistForRow_(sheet, row) {
const config = getConfig_();
const record = rowObject_(sheet, row);
if (String(config.AI_ENABLED).toUpperCase() !== "TRUE") {
throw new Error("AI is not enabled in Config.");
}
if (record["AI Allowed"] !== "Yes") {
throw new Error("Set AI Allowed to Yes after reviewing data restrictions.");
}
const apiKey = PropertiesService.getScriptProperties()
.getProperty("OPENAI_API_KEY");
if (!apiKey) {
throw new Error("OPENAI_API_KEY is missing from Script Properties.");
}
setField_(sheet, row, "AI Status", "Processing");
setField_(sheet, row, "AI Last Run", new Date());
const systemInstruction =
"You assist a B2B marketing content team. Use only the supplied brief. " +
"Do not invent customer facts, product capabilities, statistics, quotes, " +
"legal claims, or evidence. Identify missing information explicitly. " +
"Create planning suggestions, not final approval or publication decisions. " +
"Return only JSON matching the supplied schema.";
const userPrompt = [
"Record ID: " + record["Record ID"],
"Working title: " + record["Working Title"],
"Business objective: " + sanitizeForAi_(
record["Business Objective"]
),
"Audience: " + sanitizeForAi_(record["Audience"]),
"Content type: " + record["Content Type"],
"Primary channel: " + record["Channel"],
"Campaign: " + sanitizeForAi_(record["Campaign"]),
"Funnel stage: " + record["Funnel Stage"],
"Category: " + record["Category"],
"Source notes: " + sanitizeForAi_(
truncate_(record["Source Notes"], 5000)
),
"",
"Produce:",
"1. A concise brief summary.",
"2. A practical outline with the purpose of each section and evidence needed.",
"3. Up to three repurposing suggestions appropriate to the supplied channels.",
"4. Missing information and risk flags.",
"5. A confidence score from 0 to 1 based only on brief completeness."
].join("\n");
try {
const result = callAiApi_(
config,
apiKey,
systemInstruction,
userPrompt
);
const validated = validateAiOutput_(result.data);
const outputLink = createAiOutputDocument_(record, validated);
const status = validated.confidence < 0.75
? "Low Confidence"
: "Review Required";
setField_(sheet, row, "AI Status", status);
setField_(sheet, row, "AI Confidence", validated.confidence);
setField_(sheet, row, "AI Output Link", outputLink);
setField_(sheet, row, "AI Last Run", new Date());
logAi_({
recordId: record["Record ID"],
model: config.AI_MODEL,
status: status,
confidence: validated.confidence,
inputCharacters: systemInstruction.length + userPrompt.length,
outputCharacters: result.outputText.length,
inputTokens: result.inputTokens,
outputTokens: result.outputTokens,
requestId: result.requestId,
error: ""
});
logAudit_(
record["Record ID"],
"AI_ASSIST_CREATED",
eventActor_(),
"Status: " + status
);
} catch (error) {
setField_(sheet, row, "AI Status", "Error");
setField_(sheet, row, "Error Message", "AI: " + error.message);
logAi_({
recordId: record["Record ID"],
model: config.AI_MODEL,
status: "Error",
confidence: "",
inputCharacters: systemInstruction.length + userPrompt.length,
outputCharacters: 0,
inputTokens: "",
outputTokens: "",
requestId: "",
error: error.message
});
logError_(
record["Record ID"],
"AI_ASSIST",
error,
{ row: row }
);
throw error;
}
}
function callAiApi_(config, apiKey, systemInstruction, userPrompt) {
const schema = {
type: "object",
additionalProperties: false,
required: [
"brief_summary", "outline", "repurposing",
"missing_information", "risk_flags", "confidence"
],
properties: {
brief_summary: { type: "string" },
outline: {
type: "array",
items: {
type: "object",
additionalProperties: false,
required: ["heading", "purpose", "evidence_needed"],
properties: {
heading: { type: "string" },
purpose: { type: "string" },
evidence_needed: {
type: "array",
items: { type: "string" }
}
}
}
},
repurposing: {
type: "array",
maxItems: 3,
items: {
type: "object",
additionalProperties: false,
required: ["channel", "format", "draft_direction"],
properties: {
channel: { type: "string" },
format: { type: "string" },
draft_direction: { type: "string" }
}
}
},
missing_information: {
type: "array",
items: { type: "string" }
},
risk_flags: {
type: "array",
items: { type: "string" }
},
confidence: {
type: "number",
minimum: 0,
maximum: 1
}
}
};
const payload = {
model: config.AI_MODEL,
input: [
{
role: "system",
content: [{ type: "input_text", text: systemInstruction }]
},
{
role: "user",
content: [{ type: "input_text", text: userPrompt }]
}
],
text: {
format: {
type: "json_schema",
name: "content_operations_assist",
strict: true,
schema: schema
}
}
};
let lastError;
for (let attempt = 0; attempt < 3; attempt++) {
const response = UrlFetchApp.fetch(config.AI_ENDPOINT, {
method: "post",
contentType: "application/json",
headers: {
Authorization: "Bearer " + apiKey
},
payload: JSON.stringify(payload),
muteHttpExceptions: true
});
const statusCode = response.getResponseCode();
const body = response.getContentText();
const headers = response.getHeaders();
if (statusCode >= 200 && statusCode < 300) {
const parsed = JSON.parse(body);
const outputText = extractAiOutputText_(parsed);
const data = JSON.parse(outputText);
const usage = parsed.usage || {};
return {
data: data,
outputText: outputText,
inputTokens: usage.input_tokens || "",
outputTokens: usage.output_tokens || "",
requestId: headers["x-request-id"] ||
headers["X-Request-Id"] || ""
};
}
lastError = new Error(
"AI API returned HTTP " + statusCode + ": " +
truncate_(body, 1000)
);
if ([429, 500, 502, 503, 504].indexOf(statusCode) === -1) {
break;
}
Utilities.sleep(1000 * Math.pow(2, attempt));
}
throw lastError || new Error("AI API request failed.");
}
function extractAiOutputText_(response) {
if (response.output_text) {
return response.output_text;
}
const output = response.output || [];
for (let i = 0; i < output.length; i++) {
const content = output[i].content || [];
for (let j = 0; j < content.length; j++) {
if (content[j].type === "output_text" && content[j].text) {
return content[j].text;
}
}
}
throw new Error("AI response did not contain output text.");
}
function validateAiOutput_(data) {
if (!data || typeof data !== "object") {
throw new Error("AI output is not an object.");
}
if (typeof data.brief_summary !== "string" ||
!Array.isArray(data.outline) ||
!Array.isArray(data.repurposing) ||
!Array.isArray(data.missing_information) ||
!Array.isArray(data.risk_flags) ||
typeof data.confidence !== "number" ||
data.confidence < 0 ||
data.confidence > 1) {
throw new Error("AI output failed structural validation.");
}
return data;
}
function createAiOutputDocument_(record, data) {
const title = record["Record ID"] + " AI Planning Assist " +
Utilities.formatDate(
new Date(),
getSpreadsheet_().getSpreadsheetTimeZone(),
"yyyyMMdd-HHmm"
);
const doc = DocumentApp.create(title);
const body = doc.getBody();
body.appendParagraph("AI Planning Assist");
body.appendParagraph("Record ID: " + record["Record ID"]);
body.appendParagraph("Working title: " + record["Working Title"]);
body.appendParagraph(
"Review status: Human review required before use."
);
body.appendParagraph("Brief Summary");
body.appendParagraph(data.brief_summary);
body.appendParagraph("Outline");
data.outline.forEach(function(item, index) {
body.appendParagraph((index + 1) + ". " + item.heading);
body.appendParagraph("Purpose: " + item.purpose);
body.appendParagraph(
"Evidence needed: " +
(item.evidence_needed.length
? item.evidence_needed.join("; ")
: "None identified")
);
});
body.appendParagraph("Repurposing Suggestions");
data.repurposing.forEach(function(item) {
body.appendParagraph(
item.channel + " | " + item.format + " | " + item.draft_direction
);
});
body.appendParagraph("Missing Information");
body.appendParagraph(
data.missing_information.length
? data.missing_information.join("; ")
: "None identified"
);
body.appendParagraph("Risk Flags");
body.appendParagraph(
data.risk_flags.length ? data.risk_flags.join("; ") : "None identified"
);
body.appendParagraph("Model Confidence");
body.appendParagraph(String(data.confidence));
doc.saveAndClose();
const file = DriveApp.getFileById(doc.getId());
const folderUrl = clean_(record["Asset Folder Link"]);
if (folderUrl) {
const folderId = extractDriveId_(folderUrl);
if (folderId) {
file.moveTo(DriveApp.getFolderById(folderId));
}
}
return doc.getUrl();
}
function createOrGetAssetFolder_(recordId, title) {
const config = getConfig_();
if (!config.ROOT_FOLDER_ID ||
config.ROOT_FOLDER_ID.indexOf("YOUR_") === 0) {
throw new Error("ROOT_FOLDER_ID is not configured.");
}
const root = DriveApp.getFolderById(config.ROOT_FOLDER_ID);
const safeTitle = truncate_(
clean_(title).replace(/[<>:"\/\\|?*]/g, ""),
80
);
const folderName = recordId + " - " + safeTitle;
const existing = root.getFoldersByName(folderName);
if (existing.hasNext()) {
return existing.next();
}
return root.createFolder(folderName);
}
function sendCreationNotice_(record) {
sendMail_(
[record["Requester Email"], record["Owner Email"]],
"Content request created: " + record["Record ID"],
"Record: " + record["Record ID"] +
"\nTitle: " + record["Working Title"] +
"\nStatus: " + record["Status"] +
"\nRequested publish date: " +
formatDate_(record["Requested Publish Date"]) +
"\nCurrent owner: " + record["Owner Email"] +
"\nAsset folder: " + record["Asset Folder Link"] +
"\n\nThis record is not approved for publication until all gates pass."
);
}
function sendStatusNotice_(record, oldStatus, newStatus) {
sendMail_(
[record["Owner Email"]],
"Content action assigned: " + record["Record ID"],
"Record: " + record["Record ID"] +
"\nTitle: " + record["Working Title"] +
"\nPrevious status: " + oldStatus +
"\nNew status: " + newStatus +
"\nCurrent owner: " + record["Owner Email"] +
"\nStage due date: " + formatDate_(record["Stage Due Date"]) +
"\nAsset folder: " + record["Asset Folder Link"]
);
}
function sendMail_(recipients, subject, body) {
const validRecipients = Array.from(
new Set(
recipients
.map(function(value) {
return clean_(value).toLowerCase();
})
.filter(isEmail_)
)
);
if (!validRecipients.length) {
throw new Error("No valid notification recipient.");
}
MailApp.sendEmail({
to: validRecipients.join(","),
subject: subject,
body: body,
name: "Content Operations"
});
}
function resolveUser_(role, category, channel) {
const sheet = getSpreadsheet_().getSheetByName(APP.sheets.users);
const values = sheet.getDataRange().getValues();
if (values.length < 2) {
return { name: "", email: "" };
}
const headers = values[0];
let fallback = null;
for (let i = 1; i < values.length; i++) {
const user = objectFromValues_(headers, values[i]);
if (String(user.Active).toUpperCase() !== "TRUE" ||
clean_(user.Role) !== role ||
!isEmail_(clean_(user.Email))) {
continue;
}
if (!fallback) {
fallback = {
name: clean_(user.Name),
email: clean_(user.Email).toLowerCase(),
backupEmail: clean_(user["Backup Email"]).toLowerCase()
};
}
const categories = clean_(user.Categories).split("|");
const channels = clean_(user.Channels).split("|");
const categoryMatch = categories.indexOf("*") !== -1 ||
categories.indexOf(category) !== -1;
const channelMatch = channels.indexOf("*") !== -1 ||
channels.indexOf(channel) !== -1;
if (categoryMatch && channelMatch) {
return {
name: clean_(user.Name),
email: clean_(user.Email).toLowerCase(),
backupEmail: clean_(user["Backup Email"]).toLowerCase()
};
}
}
return fallback || { name: "", email: "", backupEmail: "" };
}
function calculateStageDue_(status, publishDateValue) {
if (["Published", "Cancelled"].indexOf(status) !== -1) {
return "";
}
const publishDate = asDate_(publishDateValue);
const today = dateOnly_(new Date());
if (status === "Needs Information") {
return addDays_(today, 2);
}
if (status === "Blocked") {
return addDays_(today, 1);
}
const daysBeforePublish = {
"Brief Ready": 20,
"Drafting": 14,
"Subject Review": 10,
"Design and SEO": 7,
"Final Approval": 4,
"Scheduled": 2
};
const offset = daysBeforePublish[status];
if (!publishDate || offset === undefined) {
return addDays_(today, 3);
}
const calculated = addDays_(dateOnly_(publishDate), -offset);
return calculated <= today ? addDays_(today, 3) : calculated;
}
function categorize_(contentType) {
const map = {
"Blog Article": "Thought Leadership",
"Case Study": "Customer Proof",
"Product Page": "Product Marketing",
"Webinar": "Events",
"Email Campaign": "Lifecycle Marketing",
"Social Post": "Social"
};
return map[contentType] || "Other";
}
function buildBriefSummary_(submitted, category) {
return truncate_(
submitted.contentType + " for " + submitted.audience +
" in the " + submitted.funnelStage + " stage. Objective: " +
submitted.businessObjective + ". Campaign: " + submitted.campaign +
". Reporting category: " + category + ".",
500
);
}
function buildBriefKey_(submitted) {
const raw = [
submitted.requesterEmail.toLowerCase(),
submitted.workingTitle.toLowerCase().replace(/\s+/g, " ").trim(),
submitted.campaign.toLowerCase().replace(/\s+/g, " ").trim(),
Utilities.formatDate(
submitted.publishDate,
getSpreadsheet_().getSpreadsheetTimeZone(),
"yyyy-MM-dd"
)
].join("|");
const digest = Utilities.computeDigest(
Utilities.DigestAlgorithm.SHA_256,
raw,
Utilities.Charset.UTF_8
);
return digest.map(function(byte) {
const normalized = (byte + 256) % 256;
return ("0" + normalized.toString(16)).slice(-2);
}).join("").slice(0, 24);
}
function nextRecordId_(contentSheet) {
const properties = PropertiesService.getScriptProperties();
let sequence = Number(properties.getProperty("CONTENT_SEQUENCE") || 0);
const year = new Date().getFullYear();
let candidate;
do {
sequence++;
candidate = "CNT-" + year + "-" +
String(sequence).padStart(4, "0");
} while (findRowByValue_(contentSheet, "Record ID", candidate));
properties.setProperty("CONTENT_SEQUENCE", String(sequence));
return candidate;
}
function updateResponseProcessing_(e, status, recordId, message) {
const sheet = e.range.getSheet();
const headers = sheet.getRange(
1,
1,
1,
Math.max(sheet.getLastColumn(), 1)
).getValues()[0];
const processingHeaders = [
"Processing Status", "Content Record ID",
"Processing Message", "Processed At"
];
processingHeaders.forEach(function(header) {
if (headers.indexOf(header) === -1) {
headers.push(header);
sheet.getRange(1, headers.length).setValue(header);
}
});
const map = headerMap_(sheet);
sheet.getRange(e.range.getRow(), map["Processing Status"])
.setValue(status);
sheet.getRange(e.range.getRow(), map["Content Record ID"])
.setValue(recordId);
sheet.getRange(e.range.getRow(), map["Processing Message"])
.setValue(message || "");
sheet.getRange(e.range.getRow(), map["Processed At"])
.setValue(new Date());
}
function formValue_(e, header) {
const sheet = e.range.getSheet();
const headers = sheet.getRange(
1,
1,
1,
sheet.getLastColumn()
).getValues()[0];
const index = headers.indexOf(header);
if (index === -1) {
return "";
}
return sheet.getRange(e.range.getRow(), index + 1).getValue();
}
function ensureSheet_(ss, name, headers) {
let sheet = ss.getSheetByName(name);
if (!sheet) {
sheet = ss.insertSheet(name);
}
const existing = sheet.getLastColumn()
? sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0]
: [];
headers.forEach(function(header) {
if (existing.indexOf(header) === -1) {
existing.push(header);
}
});
sheet.getRange(1, 1, 1, existing.length).setValues([existing]);
sheet.setFrozenRows(1);
return sheet;
}
function seedConfig_() {
const sheet = getSpreadsheet_().getSheetByName(APP.sheets.config);
const defaults = [
[
"ROOT_FOLDER_ID",
"YOUR_FOLDER_ID",
"Drive folder that contains all content-record folders"
],
[
"MARKETING_OPS_EMAIL",
"[email protected]",
"Escalation and failure recipient"
],
[
"ESCALATION_DAYS",
"2",
"Overdue days before marketing operations is copied"
],
[
"AI_ENABLED",
"FALSE",
"Set TRUE only after security and data review"
],
[
"AI_ENDPOINT",
"https://api.openai.com/v1/responses",
"Approved AI Responses API endpoint"
],
[
"AI_MODEL",
"YOUR_APPROVED_MODEL",
"Approved model identifier"
]
];
const existing = {};
if (sheet.getLastRow() > 1) {
sheet.getRange(2, 1, sheet.getLastRow() - 1, 1)
.getValues()
.forEach(function(row) {
existing[clean_(row[0])] = true;
});
}
defaults.forEach(function(row) {
if (!existing[row[0]]) {
sheet.appendRow(row);
}
});
}
function seedUsers_() {
const sheet = getSpreadsheet_().getSheetByName(APP.sheets.users);
if (sheet.getLastRow() > 1) {
return;
}
const rows = [
[
"Content Lead", "[email protected]", "Content Owner",
"*", "*", true, "[email protected]"
],
[
"Designer", "[email protected]", "Designer",
"*", "*", true, "[email protected]"
],
[
"Marketing Operations", "[email protected]",
"Marketing Operations", "*", "*", true,
"[email protected]"
]
];
sheet.getRange(2, 1, rows.length, rows[0].length).setValues(rows);
}
function applyValidations_() {
const sheet = getSpreadsheet_().getSheetByName(APP.sheets.content);
const map = headerMap_(sheet);
const rowCount = Math.max(sheet.getMaxRows() - 1, 1);
setListValidation_(
sheet,
2,
map["Status"],
rowCount,
APP.statuses
);
setListValidation_(
sheet,
2,
map["Priority"],
rowCount,
APP.priorities
);
setListValidation_(
sheet,
2,
map["Subject Review Status"],
rowCount,
["Pending", "Approved", "Changes Requested"]
);
setListValidation_(
sheet,
2,
map["Approval Status"],
rowCount,
["Pending", "Approved", "Changes Requested", "Rejected"]
);
[
"Design Required", "SEO Required", "Links Checked",
"Alt Text Complete", "Proofread Complete", "AI Allowed"
].forEach(function(field) {
setListValidation_(sheet, 2, map[field], rowCount, ["Yes", "No"]);
});
setListValidation_(
sheet,
2,
map["Automation Status"],
rowCount,
["Processing", "Complete", "Retry Required", "Manual Review"]
);
setListValidation_(
sheet,
2,
map["AI Status"],
rowCount,
[
"Not Requested", "Processing", "Review Required",
"Low Confidence", "Accepted", "Rejected", "Error"
]
);
}
function setListValidation_(sheet, startRow, column, rows, values) {
if (!column) {
return;
}
const rule = SpreadsheetApp.newDataValidation()
.requireValueInList(values, true)
.setAllowInvalid(false)
.build();
sheet.getRange(startRow, column, rows, 1).setDataValidation(rule);
}
function getConfig_() {
const sheet = getSpreadsheet_().getSheetByName(APP.sheets.config);
const values = sheet.getDataRange().getValues();
const config = {};
for (let i = 1; i < values.length; i++) {
const key = clean_(values[i][0]);
if (key) {
config[key] = clean_(values[i][1]);
}
}
return config;
}
function appendObjectRow_(sheet, object) {
const headers = sheet.getRange(
1,
1,
1,
sheet.getLastColumn()
).getValues()[0];
const row = headers.map(function(header) {
return Object.prototype.hasOwnProperty.call(object, header)
? object[header]
: "";
});
sheet.appendRow(row);
return sheet.getLastRow();
}
function rowObject_(sheet, row) {
const headers = sheet.getRange(
1,
1,
1,
sheet.getLastColumn()
).getValues()[0];
const values = sheet.getRange(
row,
1,
1,
headers.length
).getValues()[0];
return objectFromValues_(headers, values);
}
function objectFromValues_(headers, values) {
const object = {};
headers.forEach(function(header, index) {
object[header] = values[index];
});
return object;
}
function headerMap_(sheet) {
const headers = sheet.getRange(
1,
1,
1,
sheet.getLastColumn()
).getValues()[0];
const map = {};
headers.forEach(function(header, index) {
map[header] = index + 1;
});
return map;
}
function setField_(sheet, row, field, value) {
const column = headerMap_(sheet)[field];
if (!column) {
throw new Error("Missing field in " + sheet.getName() + ": " + field);
}
sheet.getRange(row, column).setValue(value);
}
function findRowByValue_(sheet, field, value) {
const map = headerMap_(sheet);
const column = map[field];
if (!column || sheet.getLastRow() < 2) {
return 0;
}
const match = sheet.getRange(
2,
column,
sheet.getLastRow() - 1,
1
).createTextFinder(String(value))
.matchEntireCell(true)
.findNext();
return match ? match.getRow() : 0;
}
function markRowException_(sheet, row, type, message, automationStatus) {
setField_(sheet, row, "Exception Type", type);
setField_(sheet, row, "Error Message", message);
setField_(sheet, row, "Automation Status", automationStatus);
setField_(sheet, row, "Last Automation Run", new Date());
}
function logAudit_(recordId, event, actor, details) {
const sheet = getSpreadsheet_().getSheetByName(APP.sheets.audit);
sheet.appendRow([
new Date(),
recordId,
event,
actor || "UNKNOWN",
truncate_(details || "", 2000)
]);
}
function logError_(recordId, step, error, context) {
try {
const sheet = getSpreadsheet_().getSheetByName(APP.sheets.errors);
sheet.appendRow([
new Date(),
recordId,
step,
error && error.message ? error.message : String(error),
error && error.stack ? truncate_(error.stack, 5000) : "",
truncate_(JSON.stringify(context || {}), 2000),
false,
"",
""
]);
} catch (loggingError) {
console.error(loggingError);
}
}
function logAi_(entry) {
const sheet = getSpreadsheet_().getSheetByName(APP.sheets.aiLog);
sheet.appendRow([
new Date(),
entry.recordId,
entry.model,
entry.status,
entry.confidence,
entry.inputCharacters,
entry.outputCharacters,
entry.inputTokens,
entry.outputTokens,
entry.requestId,
entry.error
]);
}
function validateChoice_(value, allowed, label) {
if (allowed.indexOf(value) === -1) {
throw new Error(label + " has an invalid value: " + value);
}
}
function getSpreadsheet_() {
const id = PropertiesService.getScriptProperties()
.getProperty("CONTENT_OPS_SPREADSHEET_ID");
if (id) {
return SpreadsheetApp.openById(id);
}
return SpreadsheetApp.getActiveSpreadsheet();
}
function clean_(value) {
if (value === null || value === undefined) {
return "";
}
if (value instanceof Date) {
return value;
}
return String(value).trim();
}
function truncate_(value, length) {
const text = clean_(value);
return text.length > length ? text.slice(0, length) : text;
}
function isEmail_(value) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(clean_(value));
}
function asDate_(value) {
if (!value) {
return null;
}
if (value instanceof Date) {
return new Date(value.getTime());
}
const parsed = new Date(value);
return isNaN(parsed.getTime()) ? null : parsed;
}
function dateOnly_(date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}
function addDays_(date, days) {
const copy = new Date(date.getTime());
copy.setDate(copy.getDate() + days);
return copy;
}
function daysBetween_(start, end) {
return Math.floor((end.getTime() - start.getTime()) / 86400000);
}
function sameDate_(a, b) {
return a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate();
}
function startOfWeek_(date) {
const copy = dateOnly_(date);
const day = copy.getDay();
const offset = day === 0 ? -6 : 1 - day;
return addDays_(copy, offset);
}
function formatDate_(value) {
const date = asDate_(value);
if (!date) {
return "Not set";
}
return Utilities.formatDate(
date,
getSpreadsheet_().getSpreadsheetTimeZone(),
"yyyy-MM-dd"
);
}
function sanitizeForAi_(value) {
return clean_(value)
.replace(/[^\s@]+@[^\s@]+\.[^\s@]+/g, "[EMAIL REDACTED]")
.replace(/\+?\d[\d\s().-]{7,}\d/g, "[PHONE REDACTED]");
}
function extractDriveId_(url) {
const match = clean_(url).match(/[-\w]{25,}/);
return match ? match[0] : "";
}
function eventActor_(e) {
try {
if (e && e.user && e.user.getEmail()) {
return e.user.getEmail();
}
const active = Session.getActiveUser().getEmail();
return active || "UNKNOWN";
} catch (error) {
return "UNKNOWN";
}
}
Configuration and Deployment Instructions
- Run
setupWorkbookmanually from the Apps Script editor. Approve the requested spreadsheet, Drive, document, mail, and external request permissions. - Open the Config sheet and replace
YOUR_FOLDER_IDwith the ID of the governed root Drive folder. - Replace the example operational email with the monitored marketing operations address.
- Replace sample Users rows with actual active users, roles, category mappings, channel mappings, and backup addresses.
- Use a vertical bar to separate multiple categories or channels in Users. Use
*as a deliberate wildcard. - Run
runConfigurationCheck. Resolve every issue before installing production triggers. - Run
installTriggers. Confirm the project contains one form-submit trigger, one edit trigger, and one daily time trigger. - Submit a test form response. Confirm that a Content Tracker row, Record ID, Drive folder, response processing status, email, audit event, and Calendar View row are created.
- Open Apps Script execution history to inspect duration, result, trigger type, and error details. The Error Log contains business-facing recovery information, while execution history contains platform-level diagnostic detail.
- For optional AI, add a Script Property named
OPENAI_API_KEY, replaceYOUR_APPROVED_MODEL, confirm the endpoint with the organization’s approved provider configuration, and setAI_ENABLEDtoTRUE.
The API request uses bearer-token authentication, POST, a JSON request body, and structured JSON output. It retries HTTP 429 and selected server failures up to three attempts with increasing delays. It does not retry ordinary validation or authorization failures.
The expected successful response contains output text that parses into the requested schema. Token usage and a provider request ID are recorded when returned. Full brief content is not copied into the AI Log.
Likely setup errors include a mismatched Form question name, inaccessible root folder, missing Users role, unauthorized Mail service, expired API credential, unapproved model identifier, or a spreadsheet header changed after setup. Correct the configuration and rerun the failed test before enabling production use.
Failure Handling and Operational Reliability
| Failure | Automated response | Manual recovery | Owner |
|---|---|---|---|
| Missing required form value | Response row marked Error; no operational record created | Correct the intake configuration or resubmit a complete request | Marketing operations |
| Duplicate submission | Existing Record ID returned; second record not created | Review whether the submission was a true duplicate or a distinct content item | Marketing operations |
| Duplicate trigger event | Brief Key lookup makes creation idempotent | Review Audit Log if the response status is uncertain | Automation owner |
| Invalid dropdown value | Submission or status change rejected and logged | Restore the governed value or update code and validation through change control | Automation owner |
| Partial record creation | Record remains with Retry Required and the failed integration named | Correct access or configuration, then run the daily monitor | Marketing operations |
| Drive folder creation failure | Asset Folder Link remains blank; retry scheduled | Restore folder access or replace ROOT_FOLDER_ID | Workspace administrator |
| Notification failure | Record marked Retry Required; Error Log entry created | Correct email or authorization and rerun monitoring | Automation owner |
| Invalid status transition | Old status retained; gate failure recorded | Add missing evidence or choose an allowed transition | Current content owner |
| Unavailable approver | Reminder and escalation continue | Authorized marketing operations user assigns a delegate and records the reason | Marketing operations |
| Authentication expiry | Triggered execution fails and is visible in execution history | Reauthorize the trigger owner or replace the service credential | Workspace or automation administrator |
| Rate limit or temporary API failure | AI request retries selected responses three times | Run the AI action later or continue without AI | Content owner |
| Malformed AI output | Structural validation fails; record receives AI Status Error | Use the original brief, retry once appropriate, or disable AI | Content owner |
| Looker Studio stale data | Operational sheet remains authoritative | Refresh Calendar View, check connector access, and refresh the report | Dashboard owner |
| Retries exhausted | Automation Status changes to Manual Review after three attempts | Resolve the root cause, reset Retry Count if authorized, and document recovery | Automation owner |
The Error Log and Manual Review queue act as a practical dead-letter process. Failed records are not discarded. Staff can filter Content Tracker for Automation Status equal to Retry Required or Manual Review, then use the Error Message, Last Automation Run, Retry Count, and Error Log to diagnose the issue.
Idempotency is strongest for record and folder creation. Record creation checks Brief Key, and folder creation checks the unique folder name before creating another folder. Email delivery is normally an at-least-once operation, so a retry can produce a repeated operational message. Every notification includes Record ID so recipients can recognize the same work item.
A weekly reconciliation compares raw form responses with Content Tracker Record IDs. Any processed response without a content record, or any active record without a folder, owner, due date, or automation status, enters Manual Review.
A Complete Example
A content marketer submits the following request:
- Working title: Reducing Approval Delays in Field Service Teams
- Business objective: Help operations leaders understand how structured approval ownership can reduce administrative delay.
- Audience: Field service operations managers
- Content type: Blog Article
- Channel: Website
- Campaign: Q3 Operations Education
- Funnel stage: Consideration
- Requested publishing date: September 18, 2026
- Priority: Standard
- Design required: Yes
- SEO required: Yes
The form-submit trigger validates the required fields and converts the requested date into a Sheets date value. The content type maps to the category Thought Leadership. Apps Script generates a duplicate key and finds no existing match.
The next sequence produces CNT-2026-0043. The Users sheet assigns the active Content Owner wildcard match. The record enters Brief Ready, receives a stage deadline, and stores the raw response row number.
Apps Script creates a Drive folder named CNT-2026-0043 - Reducing Approval Delays in Field Service Teams. The folder URL is written to Asset Folder Link. The requester and content owner receive the Record ID and folder link.
The content owner reviews the brief, changes the status to Drafting, creates a Google Doc in the folder, and records its link in Draft Link. When the owner requests Subject Review, the script confirms that Draft Link is present and assigns the named subject reviewer.
The reviewer requests one clarification about measurement language. Subject Review Status changes to Changes Requested, Review Feedback is recorded, and the workflow returns to Drafting. After revision, the owner submits it again. The reviewer selects Approved, the Subject Review Date is recorded, and the record advances to Design and SEO.
The designer adds a Design Link. The content owner enters an SEO title, meta description, and primary keyword, then marks Links Checked, Alt Text Complete, and Proofread Complete as Yes.
The record enters Final Approval. The marketing leader reviews the draft and design, then selects Approved. Apps Script confirms the approval and all publishing checks. It records Approval Date, changes the status to Scheduled, assigns marketing operations, and records Scheduled Date.
On the target date, the publisher creates the item in the content management system. The publisher records cms-1847 as the External CMS ID and adds the final public URL. The move to Published succeeds because Published URL is present. Published Date is recorded, the calendar and dashboard update, and the complete status history remains in the Audit Log.
If the Drive folder had failed during intake, the content record would still have existed with Automation Status set to Retry Required. The daily monitor would have retried the folder using the same Record ID and unique folder name.
Implementation Cost
The following figures are representative planning assumptions, not vendor quotations or verified client results. Actual licensing, professional rates, internal salaries, storage, AI usage, and implementation effort vary.
| Cost category | Assumption | Estimated amount |
|---|---|---|
| Solution design and build | 52 professional hours at $135 per hour | $7,020 |
| Testing, deployment, and documentation | 20 professional hours at $135 per hour | $2,700 |
| Internal requirements and acceptance testing | 16 internal hours at $45 per hour | $720 |
| Internal training and launch preparation | 8 internal hours at $45 per hour | $360 |
| Total representative economic implementation cost | Professional and internal time combined | $10,800 |
| Cost category | Type | Representative assumption |
|---|---|---|
| Google Workspace accounts | Existing recurring cost | Existing licenses remain required; no incremental amount assumed in the calculation |
| Storage, connector, or monitoring allowance | Recurring budget assumption | $60 per month; actual incremental cost may differ |
| Operational maintenance | Internal labour | 3 hours per month at the applicable loaded labour cost |
| Optional AI usage | Usage-based recurring cost | $18 per month assumed for the later AI comparison |
| Additional external integrations | Optional | Not included; assess if a CMS, digital asset platform, or project system is connected later |
An organization implementing with internal technical staff would replace professional service hours with its own design, engineering, testing, security, and documentation time. A tool with no incremental license cost still requires accountable labour and maintenance.
Estimated Time and Cost Savings
The estimate covers administrative coordination only. It does not assume that writing, research, design, subject review, or publishing judgment disappears.
| Assumption | Value |
|---|---|
| Monthly workflow volume | 42 content records |
| Current administrative handling time | 38 minutes per record |
| New administrative handling time | 12 minutes per record |
| Exception rate | 12 percent |
| Exception handling time | 15 minutes per exception |
| Monthly maintenance time | 3 hours |
| Loaded hourly labour cost | $42 |
| Recurring tool cost used in calculation | $60 per month |
| One-time implementation cost | $10,800 |
Current monthly labour hours: Monthly volume × current minutes per record ÷ 60
42 × 38 ÷ 60 = 26.60 hours
New monthly labour hours: Monthly volume × new minutes per record ÷ 60, plus exception handling and maintenance
(42 × 12 ÷ 60) + (42 × 12% × 15 ÷ 60) + 3 = 8.40 + 1.26 + 3.00 = 12.66 hours
Monthly hours recovered: Current monthly labour hours minus new monthly labour hours
26.60 - 12.66 = 13.94 hours
Estimated monthly labour value: Monthly hours recovered × loaded hourly labour cost
13.94 × $42 = $585.48
Net estimated monthly value: Monthly labour value minus recurring tool costs
$585.48 - $60 = $525.48
Estimated payback period: One-time implementation cost ÷ net estimated monthly value
$10,800 ÷ $525.48 = approximately 20.6 months
Recovered time does not automatically reduce payroll. It can represent additional campaign capacity, quicker review turnaround, reduced overtime, fewer administrative follow-ups, or the ability to handle a higher content volume without adding the same amount of coordination work.
Non-financial benefits include clearer ownership, fewer incomplete briefs, consistent publishing checks, easier deadline management, improved approval evidence, better auditability, and more reliable operational reporting.
Readers should replace the monthly volume, handling minutes, exception rate, maintenance time, loaded labour rate, recurring costs, and implementation cost with their own measured values. A short time study across several content types is preferable to relying on memory alone.
Adding AI to the Automation
AI is an optional enhancement added after the deterministic workflow is stable. The core benefits do not depend on AI. Forms standardize intake, rules assign owners, status gates enforce evidence, Apps Script sends reminders, Drive organizes documents, and Looker Studio reports operational data.
Potential AI uses include summarizing long source notes, suggesting outlines, proposing repurposing formats, identifying missing information, categorizing unusual requests, comparing revisions, and drafting channel-specific starting points.
AI should not replace required fields, date calculations, exact duplicate keys, approval thresholds, status rules, permissions, or publishing checks. Those tasks are more reliable and auditable when handled through validation and deterministic logic.
For this implementation, the recommended enhancement is a reviewed planning package containing a brief summary, outline, evidence requirements, and up to three repurposing suggestions.
The Recommended AI Enhancement
- Trigger: An authorized user selects a Content Tracker row and chooses Generate AI assist for selected row.
- AI input: Working title, objective, audience, type, channel, campaign, funnel stage, category, and approved source notes.
- System instruction: The model must use only supplied information and must not invent claims, statistics, quotations, evidence, or product capabilities.
- Expected output: Structured JSON containing summary, outline, repurposing suggestions, missing information, risk flags, and confidence.
- Validation: Apps Script validates object type, required arrays, and confidence range.
- Record update: A Google Doc is created in the asset folder, and AI Status, confidence, output link, and run timestamp are updated.
- Human review: The content owner reviews the document and marks AI Status Accepted or Rejected.
- Low confidence: Confidence below 0.75 produces Low Confidence and requires additional brief review.
- Prohibited data: Credentials, regulated personal data, payment data, confidential legal material, and unapproved customer information.
- Failure behavior: The record receives AI Status Error, the core workflow continues, and the content owner works from the original brief.
The reusable system instruction is:
You assist a B2B marketing content team. Use only the supplied brief. Do not invent customer facts, product capabilities, statistics, quotes, legal claims, or evidence. Identify missing information explicitly. Create planning suggestions, not final approval or publication decisions. Return only JSON matching the supplied schema.
The expected structured output follows this shape:
{
"brief_summary": "A concise summary based only on the supplied brief.",
"outline": [
{
"heading": "Proposed section heading",
"purpose": "Why this section supports the stated objective",
"evidence_needed": [
"Evidence or source that a human should provide"
]
}
],
"repurposing": [
{
"channel": "Email",
"format": "Newsletter summary",
"draft_direction": "Summarize the operational problem and link to the article"
}
],
"missing_information": [
"Information needed before drafting"
],
"risk_flags": [
"Potential unsupported claim requiring human verification"
],
"confidence": 0.82
}
AI confidence is a workflow signal, not an objective truth score. Human reviewers must verify every factual statement and decide whether any suggestion belongs in the content plan.
Benefits of the AI Enhancement
- Reduces the time spent turning a complete structured brief into an initial outline.
- Makes missing evidence more visible before drafting starts.
- Produces a consistent set of repurposing suggestions across content owners.
- Helps marketers scan long source notes without replacing source review.
- Creates structured data that can later support analysis of recurring information gaps.
- Provides a documented starting point that can be accepted, edited, or rejected.
These are AI-specific benefits. Assignment, deadline control, review gates, notifications, audit history, file organization, and dashboard reporting already come from normal automation.
What Remains Rule-Based or Human-Controlled
| Decision | Control | Reason |
|---|---|---|
| Whether the brief is complete | Required fields plus content-owner review | Many completeness requirements can be defined exactly |
| Owner assignment | Users lookup rules | Assignment must be predictable and auditable |
| Subject accuracy | Named human reviewer | AI cannot verify internal product or customer facts from an incomplete brief |
| Approval or rejection | Authorized marketing leader | This is a consequential brand and business decision |
| SEO length checks | Exact formulas and rules | Character limits do not require probabilistic interpretation |
| Legal or compliance conclusions | Qualified human review | AI suggestions are not legal or regulatory determinations |
| Publication | Authorized publisher | The final public action requires confirmation of timing, content, and destination |
| Policy exceptions | Marketing operations and leadership | Exceptions need named accountability and recorded rationale |
Estimating the Additional Value of AI
The AI estimate is separate from the core coordination savings.
| Measure | Assumption |
|---|---|
| Eligible briefs per month | 42 |
| Original manual planning task | 14 minutes per brief |
| Core workflow without AI | 10 minutes per brief because the brief is already standardized |
| Routine AI-assisted task including review | 5 minutes per brief |
| Gross additional saving | 5 minutes per brief |
| Expected correction rate | 15 percent, with 4 additional minutes |
| Expected AI failure rate | 3 percent, with 10-minute manual fallback |
| Monthly AI monitoring | 0.5 hour |
| AI usage cost | $18 per month |
Gross additional hours: 42 × 5 ÷ 60 = 3.50 hours
Correction time: 42 × 15% × 4 ÷ 60 = 0.42 hours
Failure fallback time: 42 × 3% × 10 ÷ 60 = 0.21 hours
Net additional capacity: 3.50 - 0.42 - 0.21 - 0.50 = 2.37 hours per month
Estimated labour value: 2.37 × $42 = $99.54 per month
Net value after assumed AI cost: $99.54 - $18 = $81.54 per month
The estimate does not assume that AI eliminates errors or human review. If briefs are highly specialized, sparse, sensitive, or fact-dependent, correction time may exceed the time saved.
Testing Checklist
Use fictional sample data and non-sensitive test documents before processing real marketing, customer, or product information.
| Test | Expected result |
|---|---|
| Normal submission | One record, folder, notification, audit event, and calendar row are created |
| Missing required field | Processing stops and the response row shows Error |
| Invalid field value | Server-side validation rejects the value |
| Urgent request without reason | Submission is rejected with a specific validation message |
| Duplicate submission | Existing Record ID is returned without creating another record |
| Duplicate event delivery | Brief Key prevents a duplicate operational record |
| Simultaneous submissions | Script lock produces distinct Record IDs |
| Failed Google authorization | Execution fails visibly and no false Complete status is recorded |
| Expired external credential | AI Status becomes Error while core workflow remains available |
| Failed API request | Retry occurs only for configured temporary response codes |
| Unavailable approver | Reminder, escalation, and documented reassignment work |
| Subject changes requested | Record returns to Drafting and content owner is reassigned |
| Final rejection | Record moves to Cancelled with approval evidence retained |
| Manual reassignment | New owner is recorded and Audit Log captures the field update |
| Overdue item | Item appears in the overdue view |
| Reminder | Owner receives one reminder for the day |
| Escalation | Marketing operations is copied after the configured threshold |
| Missing draft link | Move to Subject Review is rejected |
| Failed file or folder creation | Record receives Retry Required and an Error Log entry |
| Failed notification | Record is not shown as fully healthy; retry path is available |
| Unauthorized user | Protected ranges and Drive permissions prevent the action |
| Incomplete publishing checks | Approved record remains out of Scheduled until checks pass |
| Missing Published URL | Move to Published is rejected |
| Malformed AI output | Structural validation fails and AI Status becomes Error |
| Inaccurate AI suggestion | Human reviewer rejects or edits the output; core record remains unchanged |
| AI service unavailable | Content owner continues from the original brief |
| Successful completion | Published Date, URL, status, dashboard data, and audit record are correct |
| Reporting accuracy | Dashboard totals reconcile to filtered Content Tracker rows |
| Correct retry behavior | Temporary failure recovers without duplicate record or folder creation |
| Retries exhausted | Record moves to Manual Review after three attempts |
Ongoing Maintenance
The marketing operations manager is the primary system owner. A technically capable Workspace administrator or automation specialist is the backup owner.
| Frequency | Task | Owner |
|---|---|---|
| Daily | Review Manual Review, Retry Required, and failed trigger executions | Marketing operations |
| Weekly | Reconcile form responses, content records, missing folders, overdue work, and dashboard totals | Marketing operations |
| Monthly | Review Users roles, former employees, backup owners, Drive access, and notification recipients | Workspace administrator |
| Monthly | Sample AI output for unsupported claims, missing flags, and correction patterns | Content lead |
| Monthly | Review Apps Script execution duration, failures, quotas, and API usage cost | Automation owner |
| Quarterly | Test normal, rejected, overdue, retry, and credential-failure paths | Automation owner and process owner |
| Quarterly | Review form questions, status definitions, stage timing, and dashboard usefulness | Marketing leadership |
| Semiannually | Archive closed records according to retention policy and verify backups | Marketing operations |
| On staff change | Remove former users, transfer files, replace trigger ownership, and assign delegates | Workspace administrator |
| On integration change | Retest field mappings, credentials, error handling, documentation, and rollback | Automation owner |
Credential rotation should follow the organization’s security policy. API keys must be replaced in Script Properties, followed by a controlled test. Updating documentation is part of every material workflow or field change.
Templates, publishing checks, category mappings, and due-date offsets should be reviewed as the content strategy changes. Avoid adding fields without deciding who owns them, whether they are required, and how they will be reported.
When to Move to Dedicated Software
This implementation remains appropriate while the workflow is moderate in volume, centered on Google Workspace, and manageable through a small number of controlled stages.
Reassess the architecture when several of these conditions appear:
- Transaction volume causes noticeable spreadsheet or trigger performance problems.
- Multiple brands, regions, languages, or business units require separate permission boundaries.
- Review paths require complex parallel approvals or conditional legal workflows.
- A formal audit requires immutable controls beyond spreadsheet protections and append-only logs.
- Digital assets need advanced rights, licensing, rendition, and expiration management.
- The content management system must publish automatically across many destinations.
- Mobile or offline workflow access becomes operationally important.
- External agencies or customers require a secure portal.
- Exception rates and script maintenance consume more time than the automation saves.
- Advanced capacity planning, resource allocation, localization, or campaign budgeting is required.
- API volume, credential management, or security risk exceeds the support model of a bound script.
- The organization requires formal vendor support and service commitments for the workflow platform.
Possible next-step categories include work management platforms, marketing resource management systems, digital asset management platforms, content operations platforms, or a lightweight custom application backed by a managed database. Migration is not automatically necessary. The decision should compare governance, integration complexity, support requirements, lifecycle cost, and the operational value of the current system.
Implementation Checklist
- Confirm content types, volume, participants, review gates, and publishing responsibilities.
- Select and document the intake, record, automation, storage, notification, and reporting tools.
- Create managed production and test accounts.
- Assign primary and backup system owners.
- Create the governed Drive root folder and permission groups.
- Build the Google Form with exact field names, required fields, and controlled choices.
- Connect the form to the response spreadsheet.
- Create the Content Tracker, Users, Config, Audit Log, Error Log, Calendar View, and AI Log.
- Define identifiers, duplicate keys, statuses, owners, dates, and exception fields.
- Populate Users with roles, category mappings, channel mappings, and backups.
- Replace configuration placeholders and secure credentials.
- Paste, authorize, and validate the Apps Script code.
- Install the form-submit, edit, and daily monitoring triggers.
- Test field mapping from Google Forms to Content Tracker.
- Test unique ID generation and duplicate prevention.
- Test folder creation, returned links, and permission inheritance.
- Test owner assignment and status transitions.
- Test subject review, final approval, rejection, and return paths.
- Test reminders, escalations, delegation, and unavailable approvers.
- Test SEO, design, link, alt-text, proofreading, and publishing gates.
- Configure Looker Studio work queues, exceptions, calendar, throughput, and automation-health views.
- Protect automation-managed fields and restrict shared links.
- Complete security, privacy, retention, and former-user access reviews.
- Use sample records for user acceptance testing.
- Run a limited pilot before production rollout.
- Document support, rollback, retry, and manual recovery procedures.
- Replace representative cost assumptions with actual internal and vendor figures.
- Measure current and new handling time before calculating savings.
- Enable AI only after core automation, data restrictions, prompts, validation, and human review are approved.
- Assign maintenance, monitoring, documentation, and upgrade-review owners.
- Define volume, governance, security, and complexity criteria for moving to dedicated software.
Department/Function: Marketing & Customer Engagement
Get a FREE
Proof of Concept
& Consultation
No Cost, No Commitment!


