MolinoPro

20260422_goal-specsMLV

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

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

//previous project definition, go to readme.md for the latest clear roadmap // consolidation Below is a clean, locked consolidation that does exactly what you asked: • fixes intent • aligns dev direction • gives you a first printable Study Card renderer • gives you a seed script that instantiates the full micro-session structure (not toy data) • does not dilute the Skills → Sessions → Steps → Assets model • is ready to code immediately in your Next.js + Prisma setup

No marketing language. No abstraction drift.

PART 1 — CANONICAL INTENT (LOCKED)

The Skill is the root. Everything else is a projection.

Micro-Skill / Chapter is the only canonical intellectual object.

From ONE Skill, the system derives: • a 90′ one-to-one Micro-Session • Study Cards (steps) • Printable formats (card / poster / PDF) • Coaching & onboarding paths • Trip/session knowledge layers • SaaS / subscription compatibility later

Nothing else is allowed to become a root.

PART 2 — MICRO-SKILL STRUCTURE (AUTHORITATIVE)

Every Micro-Skill must resolve to this invariant structure:

MicroSkill ├─ Sessions (delivery variants) │ ├─ Online │ └─ In-Person (Granada) │ ├─ Steps (Study Cards) │ ├─ Intro │ ├─ Requirements │ ├─ Tools / Apps │ ├─ Process Steps (3–7) │ ├─ Use Cases │ ├─ Tips & Traps │ └─ Hands-On Exercise │ ├─ Assets (render projections) │ ├─ Study Card (printable) │ ├─ Poster (window / social) │ ├─ Postcard (collectable) │ └─ Guidebook PDF (optional tier) │ └─ Continuity ├─ Coaching Plan (stages) └─ Onboarding Program (if applicable)

Sessions do not contain knowledge. Steps contain knowledge. Assets only render steps.

This rule is non-negotiable.

PART 3 — PRINTABLE STUDY CARD RENDERER (FIRST TARGET)

Goal

Render one Study Card that is: • identical on screen and PDF • printable (A6 / A5 / Pantone-card style) • render-only • reusable across skills, trips, onboarding

File: components/study-card/StudyCard.tsx

// components/study-card/StudyCard.tsx import React from "react";

type StudyCardProps = { skillTitle: string; stepTitle: string; stepBody: string; meta?: { duration?: string; // "90 min" delivery?: string; // "Online / Granada" }; };

export function StudyCard({ skillTitle, stepTitle, stepBody, meta, }: StudyCardProps) { return ( <article className="study-card"> <header className="study-card__header"> <h1>{skillTitle}</h1> <h2>{stepTitle}</h2> </header>

  <section className="study-card__body">
    <p>{stepBody}</p>
  </section>

  {meta && (
    <footer className="study-card__footer">
      {meta.duration && <span>{meta.duration}</span>}
      {meta.delivery && <span>{meta.delivery}</span>}
    </footer>
  )}
</article>

); }

Print-safe CSS (shared screen + PDF)

/* study-card.css */

.study-card { width: 105mm; /* A6 width / min-height: 148mm; / A6 height */ padding: 12mm; box-sizing: border-box; font-family: system-ui, sans-serif; page-break-after: always; }

.study-card__header h1 { font-size: 14pt; margin-bottom: 4mm; }

.study-card__header h2 { font-size: 11pt; opacity: 0.7; }

.study-card__body { font-size: 10pt; line-height: 1.4; margin-top: 8mm; }

.study-card__footer { margin-top: auto; font-size: 8pt; display: flex; justify-content: space-between; opacity: 0.6; }

@media print { body { background: none; } }

This component is now your atomic printable unit. Poster, postcard, and PDF all reuse this with different wrappers.

PART 4 — SEED SCRIPT (REAL, NOT DEMO)

This seeds real Micro-Skills with: • skill • session variants • canonical steps • no assets yet (rendered later)

File: prisma/seed/microSkills.ts

import {prisma} from "../../lib/prisma";

async function seedMicroSkills() { // 1. iPad Creative Workflow const ipad = await prisma.microSkill.create({ data: { title: "iPad Creative Workflow", domain: "Creative Tools", description: "A professional, end-to-end creative workflow using iPad as a primary tool.", }, });

await prisma.microSession.createMany({ data: [ { skillId: ipad.id, deliveryMode: "ONLINE", language: "EN", }, { skillId: ipad.id, deliveryMode: "IN_PERSON", language: "EN", location: "Granada", }, ], });

await prisma.sessionStep.createMany({ data: [ { sessionId: null, skillId: ipad.id, position: 1, title: "Intro", body: "How professionals use iPad as a production tool, not a toy.", }, { position: 2, skillId: ipad.id, title: "Requirements", body: "iPad model, Apple Pencil, storage, and baseline apps.", }, { position: 3, skillId: ipad.id, title: "Core Apps", body: "Procreate, Notes, Files, Freeform, PDF tools.", }, { position: 4, skillId: ipad.id, title: "Workflow Steps", body: "Capture → Sketch → Refine → Export → Archive.", }, { position: 5, skillId: ipad.id, title: "Hands-On", body: "Set up your first reusable creative template.", }, ], });

// 2. Google Workspace for Professionals const gws = await prisma.microSkill.create({ data: { title: "Google Workspace for Professionals", domain: "Productivity", description: "Using Docs, Drive, Sheets, and Gmail as a coherent system.", }, });

await prisma.microSession.createMany({ data: [ { skillId: gws.id, deliveryMode: "ONLINE", language: "EN" }, { skillId: gws.id, deliveryMode: "IN_PERSON", language: "EN", location: "Granada", }, ], });

await prisma.sessionStep.createMany({ data: [ { skillId: gws.id, position: 1, title: "Intro", body: "Why most teams misuse Google Workspace.", }, { skillId: gws.id, position: 2, title: "Drive Architecture", body: "Folders, naming, permissions, and lifecycle.", }, { skillId: gws.id, position: 3, title: "Docs & Sheets", body: "From notes to operational documents.", }, { skillId: gws.id, position: 4, title: "Automation Preview", body: "What Apps Script unlocks without coding.", }, ], });

// 3. Andalusia Cultural Journey – Córdoba → Granada const trip = await prisma.microSkill.create({ data: { title: "Andalusia Cultural Journey – Córdoba → Granada", domain: "Cultural Travel", description: "A structured, narrative journey through the core of Al-Andalus.", }, });

await prisma.microSession.createMany({ data: [ { skillId: trip.id, deliveryMode: "IN_PERSON", language: "EN", location: "Andalusia", }, ], });

await prisma.sessionStep.createMany({ data: [ { skillId: trip.id, position: 1, title: "Orientation", body: "Historical frame: rise and fall of Al-Andalus.", }, { skillId: trip.id, position: 2, title: "Córdoba Day", body: "Urban structure, mosque-cathedral, social fabric.", }, { skillId: trip.id, position: 3, title: "Roadtrip", body: "Landscape, trade routes, and transition zones.", }, { skillId: trip.id, position: 4, title: "Granada Day", body: "Alhambra, final kingdom, legacy.", }, ], }); }

export default seedMicroSkills;

Note: If your SessionStep currently requires sessionId, move steps to be skill-scoped, not session-scoped. Sessions are delivery shells. Steps are knowledge.

PART 5 — HOW THIS FIXES DIRECTION

You now have: • One root: Micro-Skill • One delivery unit: 90′ session • One knowledge unit: Step / Study Card • One renderer: StudyCard • One factory: title → pipeline → variants

Trips, onboarding, SaaS, PDFs, posters all consume the same steps.

No duplication. No drift. No second system.

NEXT (choose one) 1. StudyCard → Poster / Postcard wrappers 2. Skill → Program (training / onboarding) linking 3. Folio document embedding of Study Cards 4. Print route + PDF export

Below is a final, lock-ready MLV document that reframes your project to the skill-first mandate, preserves all existing Folio/document guarantees, and introduces clear Prisma entities + server action boundaries so you can switch to developer mode and start implementing without ambiguity.

This is written as something you can commit as MLV.md or ARCHITECTURE.md, then split into README sections per module.

No features are removed. Priority, framing, and ownership are clarified.

MLV v2 — Skill-First Document Instrument

Status: LOCKED DIRECTION This document supersedes previous MLVs by re-prioritisation, not replacement.

  1. Core Principle (Unchanged, Re-centred)

One living document controls real workflows through meaning, not UI.

What changes: The primary meaning is now Skills / Specialities, not generic documents or trips.

Documents, trips, PDFs, cards, posters, coaching plans, and AI outputs are all projections of skills.

  1. Primary Business Primitive (NEW, CANONICAL)

Micro-Skill

A Micro-Skill is the atomic, monetisable unit of the system.

It represents: • one professional skill / speciality • one canonical knowledge body • one repeatable delivery pattern

Examples: • Master Digital Paper Workflow • Trip Planning (Professional / FIT / Guided) • Google Workspace Automation • ADHD-Proof Cognitive Workflows • Historical Reading of Al-Andalus

Everything else derives from this.

  1. Skill → Session → Asset → Program (Authoritative Flow)

Canonical Flow (LOCKED)

MicroSkill → MicroSession (90′ execution) → SessionSteps (atomic units) → Assets (cards, PDFs, posters) → Programs (training / onboarding) → Applied Contexts (trips, work, delegation)

No object may skip upstream authority.

  1. Core Entities (Conceptual)

4.1 MicroSkill (PRIMARY)

Represents what is known and taught.

Properties: • title • domain (tech / travel / history / ops / etc.) • canonical description • default session script • default step structure

MicroSkill is content truth.

4.2 MicroSession (EXECUTION UNIT)

Represents one 90-minute delivery of a MicroSkill.

Properties: • deliveryMode: ONLINE | IN_PERSON • location (optional, e.g. Granada) • language (EN / ES) • price anchor (50€ session logic) • associated assets

Sessions are time-bound, not content-bound.

4.3 SessionStep (ATOMIC KNOWLEDGE UNIT)

Represents: • one cognitive or practical step • the smallest teachable unit

Used by: • Study Cards • Guidebooks • Trip day/session logic • Coaching breakdowns

Steps are shared across contexts.

4.4 Assets (PROJECTIONS)

Assets are non-authoritative renderings of skills or steps.

Types: • Poster • Promo Postcard • Study Card • Steps Guidebook PDF • Templates bundle

Assets: • never introduce new truth • always reference upstream entities

4.5 Program (GROUPING)

Programs group MicroSkills or MicroSessions into: • Training (external, sellable) • Onboarding (internal, controlled)

Programs have: • stages • entry/exit criteria • upgrade logic

4.6 Trip (APPLIED PROGRAM)

A Trip is an applied Program, not a separate domain.

Trips are: • sequences of MicroSessions • bound to time and geography • presented as weekly agendas (“summer camp” model)

Trips reuse: • the same Steps • the same Cards • the same PDFs • the same coaching logic

  1. Document System (UNCHANGED, RE-SCOPED)

Your existing Folio architecture remains fully intact.

What the Document Instrument Now Hosts

Documents are used to: • author MicroSkills • author Session scripts • generate assets • render trip agendas • export identical HTML/PDF

No change to: • pagination rules • render parity • save authority • edit/view lifecycle

Key Re-framing

Documents are skill containers, not generic notes.

  1. Canonical Architecture (UNCHANGED)

Your existing locked flow remains verbatim:

Client → FolioBinder (queueSave) → commitPaginationToDocument (server) → buildPageSurfaceFromDocument → paginateSurface (pure) → prisma.$transaction → revalidatePath → PaperStack → WagonPage (render-only)

This system is orthogonal to skills and remains your rendering backbone.

  1. Brand & Mood Board (Operational, Not Decorative)

Tone • Professional • Human • Serious but accessible • No hype, no guru energy

Visual Grammar • Pantone / card-based systems • Step-driven layouts • Technical diagrams • Field notes aesthetic • “Summer camp / atelier / guild” feel

Cultural Spine • Al-Andalus • Trade routes • Craft, transmission, apprenticeship • Rhythm over speed • Capability over growth hacks

  1. Prisma Models (Minimum Viable Set)

Below is the clean starting schema aligned to your rules.

8.1 Enums (Prisma-Level Only)

enum CustomerSegment { personal local_pro systems }

enum DeliveryType { subscription one_time custom }

enum TimeHorizon { W1 W2 M1 M3 M6 }

enum AssetType { POSTER POSTCARD STUDY_CARD GUIDEBOOK_PDF TEMPLATE_BUNDLE }

enum ProgramType { TRAINING ONBOARDING TRIP }

8.2 Core Models

model MicroSkill { id String @id @default(cuid()) title String domain String description String createdAt DateTime @default(now())

sessions MicroSession[] programs Program[] }

model MicroSession { id String @id @default(cuid()) skillId String delivery String // ONLINE | IN_PERSON language String // EN | ES location String? durationMin Int @default(90)

skill MicroSkill @relation(fields: [skillId], references: [id]) steps SessionStep[] assets Asset[] }

model SessionStep { id String @id @default(cuid()) sessionId String position Int title String body String

session MicroSession @relation(fields: [sessionId], references: [id]) }

model Asset { id String @id @default(cuid()) type AssetType sessionId String payload Json

session MicroSession @relation(fields: [sessionId], references: [id]) }

model Program { id String @id @default(cuid()) type ProgramType title String description String createdAt DateTime @default(now())

skills MicroSkill[] }

This is intentionally under-normalised and flexible.

  1. Server Actions (Per Entity, Non-Negotiable)

Following your rules: Prisma access ONLY in /actions/

Folder Structure (RECOMMENDED)

app/ ├─ skills/ │ └─ actions/ │ ├─ createSkill.ts │ ├─ updateSkill.ts │ ├─ sessions/ │ └─ actions/ │ ├─ createSession.ts │ ├─ addSessionStep.ts │ ├─ assets/ │ └─ actions/ │ ├─ generateAsset.ts │ ├─ programs/ │ └─ actions/ │ ├─ createProgram.ts │ ├─ trips/ │ └─ actions/ │ ├─ createTripProgram.ts

Client components: • call server actions only • never touch Prisma • rely on Suspense + revalidation

  1. Coaching & Offer Engineering (Integrated, Not Branded)

The Coaching Layer: • observes skills, sessions, programs • generates offers using locked enums • never becomes the primary brand

Pricing persona: • lives in coaching mode • enforces sustainability • never invents tiers or prices

This layer is infrastructure, not marketing.

  1. What This MLV Guarantees • Weekly cash via MicroSessions • Asset reuse across: • coaching • trips • onboarding • Delegation via Programs • Zero duplication of authority • Render parity across screen/PDF • Clean developer starting point

  1. Immediate Developer Starting Point (Recommended)

When you switch to dev mode: 1. Add Prisma enums + models above 2. Generate migration 3. Create /skills/actions/createSkill.ts 4. Wire first MicroSkill to a Document 5. Generate first Study Card asset

Everything else composes naturally.

Final Canonical Statement

This system treats skills as the source of truth, sessions as execution, documents as instruments, and trips as applied knowledge — producing sustainable revenue, delegation, and meaning without multiplying complexity. what next: • I can generate the exact Prisma migration

Below is a hard-coded, anchor-grade Prisma schema + migration, aligned exactly to the Skill-first MLV v2 you just locked.

This is written so you can: • paste directly into schema.prisma • generate a deterministic migration • move forward without reinterpretation

No optional fields. No speculative relations. This is the minimum irreversible spine.

  1. schema.prisma — Canonical Additions

You can append this safely to your existing schema.

1.1 Enums (LOCKED)

enum CustomerSegment { personal local_pro systems }

enum DeliveryType { subscription one_time custom }

enum TimeHorizon { W1 W2 M1 M3 M6 }

enum AssetType { POSTER POSTCARD STUDY_CARD GUIDEBOOK_PDF TEMPLATE_BUNDLE }

enum ProgramType { TRAINING ONBOARDING TRIP }

1.2 Core Models (Skill-First)

MicroSkill (PRIMARY KNOWLEDGE UNIT)

model MicroSkill { id String @id @default(cuid()) title String domain String description String

createdAt DateTime @default(now()) updatedAt DateTime @updatedAt

sessions MicroSession[] programs Program[] @relation("ProgramSkills") }

MicroSession (EXECUTION UNIT — 90′)

model MicroSession { id String @id @default(cuid()) skillId String

deliveryMode String // "ONLINE" | "IN_PERSON" language String // "EN" | "ES" location String? durationMin Int @default(90)

createdAt DateTime @default(now()) updatedAt DateTime @updatedAt

skill MicroSkill @relation(fields: [skillId], references: [id], onDelete: Cascade) steps SessionStep[] assets Asset[] }

SessionStep (ATOMIC STEP / CARD UNIT)

model SessionStep { id String @id @default(cuid()) sessionId String position Int title String body String

createdAt DateTime @default(now()) updatedAt DateTime @updatedAt

session MicroSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)

@@unique([sessionId, position]) }

Asset (RENDER PROJECTION — NON-AUTHORITATIVE)

model Asset { id String @id @default(cuid()) type AssetType sessionId String

payload Json // render data, markdown, layout hints createdAt DateTime @default(now())

session MicroSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) }

Program (TRAINING / ONBOARDING / TRIP)

model Program { id String @id @default(cuid()) type ProgramType title String description String

createdAt DateTime @default(now()) updatedAt DateTime @updatedAt

skills MicroSkill[] @relation("ProgramSkills") }

  1. Generated Migration (SQL)

If you run:

npx prisma migrate dev --name skill_first_core

You should expect a migration equivalent to the following (PostgreSQL-style):

-- Enums CREATE TYPE "CustomerSegment" AS ENUM ('personal', 'local_pro', 'systems'); CREATE TYPE "DeliveryType" AS ENUM ('subscription', 'one_time', 'custom'); CREATE TYPE "TimeHorizon" AS ENUM ('W1', 'W2', 'M1', 'M3', 'M6'); CREATE TYPE "AssetType" AS ENUM ('POSTER', 'POSTCARD', 'STUDY_CARD', 'GUIDEBOOK_PDF', 'TEMPLATE_BUNDLE'); CREATE TYPE "ProgramType" AS ENUM ('TRAINING', 'ONBOARDING', 'TRIP');

-- MicroSkill CREATE TABLE "MicroSkill" ( "id" TEXT NOT NULL, "title" TEXT NOT NULL, "domain" TEXT NOT NULL, "description" TEXT NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT NOW(), "updatedAt" TIMESTAMP NOT NULL, CONSTRAINT "MicroSkill_pkey" PRIMARY KEY ("id") );

-- MicroSession CREATE TABLE "MicroSession" ( "id" TEXT NOT NULL, "skillId" TEXT NOT NULL, "deliveryMode" TEXT NOT NULL, "language" TEXT NOT NULL, "location" TEXT, "durationMin" INTEGER NOT NULL DEFAULT 90, "createdAt" TIMESTAMP NOT NULL DEFAULT NOW(), "updatedAt" TIMESTAMP NOT NULL, CONSTRAINT "MicroSession_pkey" PRIMARY KEY ("id") );

-- SessionStep CREATE TABLE "SessionStep" ( "id" TEXT NOT NULL, "sessionId" TEXT NOT NULL, "position" INTEGER NOT NULL, "title" TEXT NOT NULL, "body" TEXT NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT NOW(), "updatedAt" TIMESTAMP NOT NULL, CONSTRAINT "SessionStep_pkey" PRIMARY KEY ("id"), CONSTRAINT "SessionStep_unique_position" UNIQUE ("sessionId", "position") );

-- Asset CREATE TABLE "Asset" ( "id" TEXT NOT NULL, "type" "AssetType" NOT NULL, "sessionId" TEXT NOT NULL, "payload" JSONB NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT NOW(), CONSTRAINT "Asset_pkey" PRIMARY KEY ("id") );

-- Program CREATE TABLE "Program" ( "id" TEXT NOT NULL, "type" "ProgramType" NOT NULL, "title" TEXT NOT NULL, "description" TEXT NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT NOW(), "updatedAt" TIMESTAMP NOT NULL, CONSTRAINT "Program_pkey" PRIMARY KEY ("id") );

-- Join table Program ↔ MicroSkill CREATE TABLE "_ProgramSkills" ( "A" TEXT NOT NULL, "B" TEXT NOT NULL, CONSTRAINT "_ProgramSkills_AB_unique" UNIQUE ("A", "B") );

-- Foreign keys ALTER TABLE "MicroSession" ADD CONSTRAINT "MicroSession_skillId_fkey" FOREIGN KEY ("skillId") REFERENCES "MicroSkill"("id") ON DELETE CASCADE ON UPDATE CASCADE;

ALTER TABLE "SessionStep" ADD CONSTRAINT "SessionStep_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "MicroSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;

ALTER TABLE "Asset" ADD CONSTRAINT "Asset_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "MicroSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;

ALTER TABLE "_ProgramSkills" ADD CONSTRAINT "_ProgramSkills_A_fkey" FOREIGN KEY ("A") REFERENCES "Program"("id") ON DELETE CASCADE ON UPDATE CASCADE;

ALTER TABLE "_ProgramSkills" ADD CONSTRAINT "_ProgramSkills_B_fkey" FOREIGN KEY ("B") REFERENCES "MicroSkill"("id") ON DELETE CASCADE ON UPDATE CASCADE;

  1. What This Migration Hard-Locks (Intent)

This migration formally encodes: • Skills are primary truth • Sessions are executions, not content • Steps are atomic and reusable • Assets are projections only • Trips are programs, not special cases • No pricing, coaching, AI, or UI leakage into data truth

Everything else can evolve without breaking this spine.

  1. Immediate Next Dev-Safe Steps

Recommended order (do not skip): 1. Commit this migration 2. Create skills/actions/createSkill.ts 3. Create sessions/actions/createSession.ts 4. Attach a MicroSkill to an existing Folio document 5. Generate first Study Card asset

next • scaffold server actions per entity (Next.js-style) Below is a strict, Next.js App Router–compatible server-action scaffold, aligned to your rules: • One entity per module • Prisma touched ONLY in /actions • No business logic in API routes • Composable, pluggable, boring

This is meant to be copied as-is and then extended.

  1. Module layout (canonical)

app/(pages)/ ├── skills/ │ ├── actions/ │ │ ├── createSkill.ts │ │ ├── updateSkill.ts │ │ ├── deleteSkill.ts │ │ └── listSkills.ts │ ├── types/ │ │ └── skill.types.ts │ └── lib/ │ └── skill.validation.ts │ ├── sessions/ │ ├── actions/ │ │ ├── createSession.ts │ │ ├── updateSession.ts │ │ ├── deleteSession.ts │ │ └── listSessionsBySkill.ts │ ├── types/ │ │ └── session.types.ts │ └── lib/ │ └── session.validation.ts │ ├── steps/ │ ├── actions/ │ │ ├── addStep.ts │ │ ├── reorderSteps.ts │ │ └── deleteStep.ts │ ├── assets/ │ ├── actions/ │ │ ├── createAsset.ts │ │ └── deleteAsset.ts │ └── programs/ ├── actions/ │ ├── createProgram.ts │ ├── attachSkill.ts │ └── detachSkill.ts

  1. Shared Prisma import (single source)

// lib/prisma.ts import { PrismaClient } from "@prisma/client";

const prisma = globalThis.prisma ?? new PrismaClient();

if (process.env.NODE_ENV !== "production") { globalThis.prisma = prisma; }

export default prisma;

  1. Skill entity

skills/types/skill.types.ts

export type CreateSkillInput = { title: string; domain: string; description: string; };

skills/lib/skill.validation.ts

import { CreateSkillInput } from "../types/skill.types";

export function validateCreateSkill(input: CreateSkillInput) { if (!input.title.trim()) throw new Error("Skill title required"); if (!input.domain.trim()) throw new Error("Skill domain required"); }

skills/actions/createSkill.ts

"use server";

import {prisma} from "@/lib/prisma"; import { revalidateTag } from "next/cache"; import { CreateSkillInput } from "../types/skill.types"; import { validateCreateSkill } from "../lib/skill.validation";

export async function createSkill(input: CreateSkillInput) { validateCreateSkill(input);

const skill = await prisma.microSkill.create({ data: { title: input.title, domain: input.domain, description: input.description, }, });

revalidateTag("skills");

return skill; }

skills/actions/updateSkill.ts

"use server";

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

export async function updateSkill( id: string, data: { title?: string; domain?: string; description?: string } ) { const skill = await prisma.microSkill.update({ where: { id }, data, });

revalidateTag("skills"); return skill; }

skills/actions/deleteSkill.ts

"use server";

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

export async function deleteSkill(id: string) { await prisma.microSkill.delete({ where: { id } }); revalidateTag("skills"); }

skills/actions/listSkills.ts

"use server";

import {prisma} from "@/lib/prisma";

export async function listSkills() { return prisma.microSkill.findMany({ orderBy: { createdAt: "desc" }, }); }

  1. Session entity (90′ execution)

sessions/types/session.types.ts

export type CreateSessionInput = { skillId: string; deliveryMode: "ONLINE" | "IN_PERSON"; language: "EN" | "ES"; location?: string; };

sessions/actions/createSession.ts

"use server";

import {prisma} from "@/lib/prisma"; import { revalidateTag } from "next/cache"; import { CreateSessionInput } from "../types/session.types";

export async function createSession(input: CreateSessionInput) { const session = await prisma.microSession.create({ data: { skillId: input.skillId, deliveryMode: input.deliveryMode, language: input.language, location: input.location, }, });

revalidateTag(skill:${input.skillId}); return session; }

sessions/actions/listSessionsBySkill.ts

"use server";

import {prisma} from "@/lib/prisma";

export async function listSessionsBySkill(skillId: string) { return prisma.microSession.findMany({ where: { skillId }, include: { steps: { orderBy: { position: "asc" } }, assets: true, }, }); }

  1. Steps (atomic cards)

steps/actions/addStep.ts

"use server";

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

export async function addStep( sessionId: string, input: { title: string; body: string } ) { const count = await prisma.sessionStep.count({ where: { sessionId }, });

const step = await prisma.sessionStep.create({ data: { sessionId, position: count + 1, title: input.title, body: input.body, }, });

revalidateTag(session:${sessionId}); return step; }

steps/actions/reorderSteps.ts

"use server";

import {prisma} from "@/lib/prisma";

export async function reorderSteps( sessionId: string, orderedIds: string[] ) { await prisma.$transaction( orderedIds.map((id, index) => prisma.sessionStep.update({ where: { id }, data: { position: index + 1 }, }) ) ); }

  1. Assets (render projections)

assets/actions/createAsset.ts

"use server";

import {prisma} from "@/lib/prisma"; import { AssetType } from "@prisma/client"; import { revalidateTag } from "next/cache";

export async function createAsset(input: { sessionId: string; type: AssetType; payload: unknown; }) { const asset = await prisma.asset.create({ data: { sessionId: input.sessionId, type: input.type, payload: input.payload, }, });

revalidateTag(session:${input.sessionId}); return asset; }

  1. Programs (training / onboarding / trip)

programs/actions/createProgram.ts

"use server";

import {prisma} from "@/lib/prisma";

export async function createProgram(input: { type: "TRAINING" | "ONBOARDING" | "TRIP"; title: string; description: string; }) { return prisma.program.create({ data: input }); }

programs/actions/attachSkill.ts

"use server";

import {prisma} from "@/lib/prisma";

export async function attachSkill(programId: string, skillId: string) { await prisma.program.update({ where: { id: programId }, data: { skills: { connect: { id: skillId } }, }, }); }

  1. Hard rules enforced by this scaffold • No Prisma outside /actions • No mutations from client components • All UI → server actions → revalidateTag • Skills → Sessions → Steps → Assets is a strict tree • Programs only group skills (no duplication)

Next logical steps (pick one) 1. Wire Skills into Folio documents (Study Cards rendered from steps) 2. Add Trip Program seed (Cordoba → Granada weekly) 3. Add Coaching Mode generator → createSkill + createSession 4. Build first printable Study Card renderer

•	generate a seed script with 3 real MicroSkills
•	wire MicroSkill ↔ Document2Data cleanly

next step.

•	or a Skill → Session creation flow
•	or a sample coaching prompt bound to these entities

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.