MolinoPro

appscript-office-server-firstLoop

Master Codebase Guidebook
Markdown + HTML Dev-Docs Renderer - Frontend Client Module

Default Index
Open README.md
Root: README.mdappscript
Milestones

Yes. The next stable move is to canonize the Apps Script interface, not the individual scripts.

Your GAS logic can stay diverse internally. What must become uniform is: • request envelope • response envelope • auth • error format • trace/log ids • action naming • payload shape

Canonical contract

Request from your app to GAS

type GoogleToolRequest<TInput = unknown> = { action: string; requestId: string; source: "folio" | "trip" | "product" | "experience" | "offer" | "assistant"; entityType?: string; entityId?: string | number; documentId?: number; userId?: string; payload: TInput; };

Response from GAS to your app

type GoogleToolResponse<TResult = unknown> = { ok: boolean; action: string; requestId: string; timestamp: string; result?: TResult; error?: { code: string; message: string; details?: unknown; }; meta?: { spreadsheetId?: string; documentId?: string; fileId?: string; fileUrl?: string; rowsProcessed?: number; batchComplete?: boolean; nextCursor?: string | number | null; durationMs?: number; }; };

Canonical Apps Script router

Instead of exposing many ad hoc script entrypoints, use one doPost(e) router.

function doPost(e) { const startedAt = Date.now();

try { const body = JSON.parse(e.postData.contents || "{}"); const action = body.action; const requestId = body.requestId || Utilities.getUuid();

if (!action) {
  return jsonResponse({
    ok: false,
    action: "unknown",
    requestId,
    timestamp: new Date().toISOString(),
    error: {
      code: "MISSING_ACTION",
      message: "Missing action field",
    },
    meta: { durationMs: Date.now() - startedAt },
  });
}

let result;

switch (action) {
  case "coreOffice.generateTemplates":
    result = CoreOfficeHandlers.generateTemplates(body.payload || {});
    break;

  case "coreOffice.generateEntityDocs":
    result = CoreOfficeHandlers.generateEntityDocs(body.payload || {});
    break;

  case "docs.create":
    result = DocsHandlers.create(body.payload || {});
    break;

  case "docs.exportPdf":
    result = DocsHandlers.exportPdf(body.payload || {});
    break;

  case "gmail.send":
    result = GmailHandlers.send(body.payload || {});
    break;

  case "calendar.createEvent":
    result = CalendarHandlers.createEvent(body.payload || {});
    break;

  case "sheets.appendRows":
    result = SheetsHandlers.appendRows(body.payload || {});
    break;

  default:
    return jsonResponse({
      ok: false,
      action,
      requestId,
      timestamp: new Date().toISOString(),
      error: {
        code: "UNKNOWN_ACTION",
        message: "Unsupported action: " + action,
      },
      meta: { durationMs: Date.now() - startedAt },
    });
}

return jsonResponse({
  ok: true,
  action,
  requestId,
  timestamp: new Date().toISOString(),
  result: result || {},
  meta: {
    durationMs: Date.now() - startedAt,
  },
});

} catch (err) { return jsonResponse({ ok: false, action: "unhandled", requestId: Utilities.getUuid(), timestamp: new Date().toISOString(), error: { code: "UNHANDLED_ERROR", message: err && err.message ? err.message : String(err), }, meta: { durationMs: Date.now() - startedAt, }, }); } }

function jsonResponse(obj) { return ContentService .createTextOutput(JSON.stringify(obj)) .setMimeType(ContentService.MimeType.JSON); }

Canonical handler style inside GAS

Your current script can be adapted very cleanly.

var CoreOfficeHandlers = (function () { function generateTemplates(payload) { CoreOfficeServer.generateTemplates();

return {
  message: "Templates generated",
};

}

function generateEntityDocs(payload) { const result = CoreOfficeServer.generateEntityDocs(payload || {});

return {
  message: "Entity docs processed",
  rowsProcessed: result.rowsProcessed || 0,
  batchComplete: result.batchComplete !== false,
  nextCursor: result.nextCursor ?? null,
};

}

return { generateTemplates: generateTemplates, generateEntityDocs: generateEntityDocs, }; })();

Adapt your existing GAS function to return structured results

Your current generateEntityDocs() should stop being UI-only and return machine data.

Replace its end behavior with something like:

function generateEntityDocs(options) { const startTime = Date.now(); const configSheet = _getConfigSheet(); const configData = configSheet.getDataRange().getValues(); const configMap = {};

let totalProcessed = 0; let batchComplete = true; let nextCursor = null; const createdDocs = [];

for (let i = 1; i < configData.length; i++) { const name = configData[i][0]; if (_shouldIgnore(name)) continue; configMap[name] = { templateId: configData[i][1], lastRow: parseInt(configData[i][2], 10) || 1, configRow: i + 1 }; }

_getEntities().forEach(entity => { const conf = configMap[entity.name]; if (!conf || !conf.templateId) return;

const sheet = entity.sheet;
const data = sheet.getDataRange().getValues();
if (data.length < 2) return;

const headers = data[0];
let docIdCol = headers.length;

if (headers[docIdCol - 1] !== 'DOC ID') {
  sheet.getRange(1, docIdCol + 1).setValue('DOC ID');
  docIdCol = headers.length;
} else {
  docIdCol = headers.length - 1;
}

const idxRefExp = headers.indexOf('RefExp');
const idxName = headers.indexOf('Name');
const idxNombre = headers.indexOf('Nombre');

let processedForEntity = 0;

for (let r = conf.lastRow; r < data.length; r++) {
  if (Date.now() - startTime > TIME_LIMIT_MS) {
    batchComplete = false;
    nextCursor = { entity: entity.name, row: r + 1 };
    return;
  }

  const docIdCell = sheet.getRange(r + 1, docIdCol + 1);
  if (data[r][docIdCol] && data[r][docIdCol].toString().trim() !== "") {
    continue;
  }

  let docTitle = "";
  if (idxRefExp !== -1 && data[r][idxRefExp]) {
    docTitle = String(data[r][idxRefExp]);
  } else if (idxName !== -1 && data[r][idxName]) {
    docTitle = String(data[r][idxName]);
  } else if (idxNombre !== -1 && data[r][idxNombre]) {
    docTitle = String(data[r][idxNombre]);
  } else {
    docTitle = `${entity.name} - Row ${r}`;
  }

  const templateFile = DriveApp.getFileById(conf.templateId);
  const copyFile = templateFile.makeCopy(docTitle, _getTemplateFolder());
  const doc = DocumentApp.openById(copyFile.getId());
  const body = doc.getBody();

  headers.forEach((h, i) => {
    body.replaceText(`\\{\\{${h}\\}\\}`, data[r][i] !== undefined ? String(data[r][i]) : "");
  });

  doc.saveAndClose();

  const url = doc.getUrl();
  docIdCell.setFormula(`=HYPERLINK("${url}", "Doc Link")`);

  createdDocs.push({
    entity: entity.name,
    row: r + 1,
    title: docTitle,
    fileId: doc.getId(),
    url: url,
  });

  processedForEntity++;
  totalProcessed++;
  configSheet.getRange(conf.configRow, 3).setValue(r + 1);

  if (processedForEntity >= BATCH_SIZE) {
    batchComplete = false;
    nextCursor = { entity: entity.name, row: r + 2 };
    break;
  }
}

});

return { rowsProcessed: totalProcessed, batchComplete: batchComplete, nextCursor: nextCursor, createdDocs: createdDocs, }; }

What your Next.js server action should do

Your server action should: 1. build canonical payload 2. call GAS endpoint 3. validate response 4. persist response meta to Prisma 5. optionally update document footer/meta/history 6. revalidate path

Example pattern

"use server";

import { revalidatePath } from "next/cache"; import { prisma } from "@/lib/prisma";

type RunGoogleToolInput<T = unknown> = { action: string; source: "folio" | "trip" | "product" | "experience" | "offer" | "assistant"; documentId?: number; entityType?: string; entityId?: string | number; payload: T; };

export async function runGoogleTool<TPayload, TResult>( input: RunGoogleToolInput<TPayload> ) { const requestId = crypto.randomUUID();

const res = await fetch(process.env.GAS_WEBAPP_URL!, { method: "POST", headers: { "Content-Type": "application/json", "x-api-key": process.env.GAS_SHARED_SECRET!, }, body: JSON.stringify({ action: input.action, requestId, source: input.source, documentId: input.documentId, entityType: input.entityType, entityId: input.entityId, payload: input.payload, }), cache: "no-store", });

const json = await res.json();

if (!json.ok) { throw new Error(json?.error?.message || "Google tool failed"); }

if (input.documentId) { await prisma.projectDocument.update({ where: { id: input.documentId }, data: { data: JSON.stringify({ requestId, lastGoogleToolAction: input.action, lastGoogleToolAt: new Date().toISOString(), lastGoogleToolMeta: json.meta ?? {}, }), }, }); revalidatePath(/documents/${input.documentId}); }

return json as TResult; }

Best toolbar integration pattern

For toolbar direct commands:

Example • button: “Generate Google Doc from this document” • toolbar click • calls server action generateGoogleDocFromDocument(documentId) • server action: • reads current document data • converts to markdown/plain text • calls runGoogleTool({ action: "docs.create", ... }) • stores returned doc URL/id in document meta • revalidates

That is the right pattern.

For coaching mode

This fits very well.

Coaching mode should do: • parse user intent • fill entity form draft • precompute action payload • show user preview or form review • optional confirm • call server action • server action calls internal Prisma actions and/or GAS tools

So coaching mode becomes: • intent-to-structured-payload • not just chat

Exactly as you described.

Recommended persistent log model

Add one generic operation log table later if needed:

model ToolRunLog { id String @id @default(cuid()) source String action String requestId String @unique entityType String? entityId String? documentId Int? ok Boolean response Json? error Json? createdAt DateTime @default(now()) }

That will make the whole toolchain traceable.

Final conclusion

Your path is now clear:

Canonical rule • GAS = atomic external executors • Next actions = orchestration + persistence + routing • toolbar = command surface • sidebar = context composition • coaching mode = structured intent-to-work layer

Immediate next best move

Take your existing GAS script and wrap it in: • doPost • action • requestId • structured JSON response

Then wire one real toolbar command end-to-end: • Generate Google Doc from this document

That is the cleanest first production loop.