From 739a576ec82fb9301aa01cd7d0b190c8bf3c1421 Mon Sep 17 00:00:00 2001 From: ElasticBottle Date: Fri, 30 Jan 2026 05:35:33 -0600 Subject: [PATCH 01/60] feat(db): add support for strategy items --- packages/core/src/schemas/content-parsers.ts | 3 +- packages/core/src/schemas/strategy-parsers.ts | 61 ++++++++ packages/db/src/operations/index.ts | 1 + .../src/operations/seo/content-operations.ts | 3 +- .../src/operations/seo/strategy-operations.ts | 142 ++++++++++++++++++ .../db/src/schema/seo/content-draft-schema.ts | 13 ++ packages/db/src/schema/seo/index.ts | 5 + .../seo/strategy-phase-content-schema.ts | 74 +++++++++ .../src/schema/seo/strategy-phase-schema.ts | 85 +++++++++++ packages/db/src/schema/seo/strategy-schema.ts | 67 +++++++++ .../seo/strategy-snapshot-content-schema.ts | 73 +++++++++ .../schema/seo/strategy-snapshot-schema.ts | 77 ++++++++++ 12 files changed, 602 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/schemas/strategy-parsers.ts create mode 100644 packages/db/src/operations/seo/strategy-operations.ts create mode 100644 packages/db/src/schema/seo/strategy-phase-content-schema.ts create mode 100644 packages/db/src/schema/seo/strategy-phase-schema.ts create mode 100644 packages/db/src/schema/seo/strategy-schema.ts create mode 100644 packages/db/src/schema/seo/strategy-snapshot-content-schema.ts create mode 100644 packages/db/src/schema/seo/strategy-snapshot-schema.ts diff --git a/packages/core/src/schemas/content-parsers.ts b/packages/core/src/schemas/content-parsers.ts index e3e801e7..acb20434 100644 --- a/packages/core/src/schemas/content-parsers.ts +++ b/packages/core/src/schemas/content-parsers.ts @@ -14,7 +14,7 @@ import { type } from "arktype"; * - scheduled: Approved, ready to be published at specified date */ export const contentStatusSchema = type( - "'suggested'|'suggestion-rejected'|'queued'|'planning'|'writing'|'reviewing-writing'|'pending-review'|'review-denied'|'scheduled'", + "'suggested'|'suggestion-rejected'|'queued'|'planning'|'writing'|'reviewing-writing'|'pending-review'|'review-denied'|'scheduled'|'published'", ).describe("The status of the content draft"); export const CONTENT_STATUSES = [ @@ -27,6 +27,7 @@ export const CONTENT_STATUSES = [ "pending-review", "review-denied", "scheduled", + "published", ] as const satisfies (typeof contentStatusSchema.infer)[]; export type SeoFileStatus = (typeof CONTENT_STATUSES)[number]; diff --git a/packages/core/src/schemas/strategy-parsers.ts b/packages/core/src/schemas/strategy-parsers.ts new file mode 100644 index 00000000..52375d27 --- /dev/null +++ b/packages/core/src/schemas/strategy-parsers.ts @@ -0,0 +1,61 @@ +import { type } from "arktype"; + +export const strategyGoalSchema = type({ + metric: "'conversions'|'clicks'|'impressions'|'avgPosition'", + target: "number", + timeframe: "'monthly'|'total'", +}).describe("Target metric for a strategy."); + +export const strategyStatuses = [ + "suggestion", + "active", + "observing", + "stable", + "archived", + "dismissed", +] as const; + +export const strategyPhaseTypes = ["build", "optimize", "expand"] as const; +export const strategyPhaseStatuses = [ + "suggestion", + "planned", + "in_progress", + "observing", + "completed", + "dismissed", +] as const; +export const contentStrategyPhaseActions = [ + "create", + "improve", + "expand", +] as const; +export const contentStrategyPhaseRoles = ["pillar", "supporting"] as const; + +export const strategySnapshotTriggers = [ + "scheduled", + "phase_complete", + "manual", +] as const; + +export type KeywordSnapshot = { + keyword: string; + position: number; + clicks: number; + impressions: number; + volume: number | null; +}; + +export type SnapshotAggregate = { + clicks: number; + impressions: number; + ctr: number; + avgPosition: number; + conversions: number | null; +}; + +export type SnapshotDelta = { + clicks: number; + impressions: number; + avgPosition: number; + conversions: number | null; +}; diff --git a/packages/db/src/operations/index.ts b/packages/db/src/operations/index.ts index 15a5f9e4..684e8b43 100644 --- a/packages/db/src/operations/index.ts +++ b/packages/db/src/operations/index.ts @@ -7,3 +7,4 @@ export * from "./seo/integration-operations"; export * from "./seo/organization-operations"; export * from "./seo/project-author-operations"; export * from "./seo/project-operations"; +export * from "./seo/strategy-operations"; diff --git a/packages/db/src/operations/seo/content-operations.ts b/packages/db/src/operations/seo/content-operations.ts index 072f33d4..a87962ab 100644 --- a/packages/db/src/operations/seo/content-operations.ts +++ b/packages/db/src/operations/seo/content-operations.ts @@ -3,6 +3,7 @@ import { err, ok, safe } from "@rectangular-labs/result"; import { and, type DB, + type DBTransaction, desc, eq, inArray, @@ -236,7 +237,7 @@ export async function getScheduledItems(args: { // ============================================================================= export async function createContentDraft( - db: DB, + db: DB | DBTransaction, values: typeof seoContentDraftInsertSchema.infer, ) { const result = await safe(() => diff --git a/packages/db/src/operations/seo/strategy-operations.ts b/packages/db/src/operations/seo/strategy-operations.ts new file mode 100644 index 00000000..70453a00 --- /dev/null +++ b/packages/db/src/operations/seo/strategy-operations.ts @@ -0,0 +1,142 @@ +import { err, ok, safe } from "@rectangular-labs/result"; +import { and, type DB, type DBTransaction, eq, schema } from "../../client"; +import type { + seoStrategyInsertSchema, + seoStrategyPhaseContentInsertSchema, + seoStrategyPhaseInsertSchema, + seoStrategyPhaseUpdateSchema, +} from "../../schema/seo"; + +export async function listStrategiesByProjectId(args: { + db: DB | DBTransaction; + projectId: string; +}) { + return await safe(() => + args.db.query.seoStrategy.findMany({ + where: (table, { eq, isNull, and }) => + and(eq(table.projectId, args.projectId), isNull(table.deletedAt)), + orderBy: (fields, { desc }) => [desc(fields.updatedAt)], + with: { + phases: { + where: (table, { isNull }) => isNull(table.deletedAt), + orderBy: (fields, { desc }) => [desc(fields.createdAt)], + }, + }, + }), + ); +} + +export async function getStrategyDetails(args: { + db: DB | DBTransaction; + projectId: string; + strategyId: string; +}) { + return await safe(() => + args.db.query.seoStrategy.findFirst({ + where: (table, { eq, isNull, and }) => + and( + eq(table.projectId, args.projectId), + eq(table.id, args.strategyId), + isNull(table.deletedAt), + ), + with: { + phases: { + where: (table, { isNull }) => isNull(table.deletedAt), + orderBy: (fields, { asc }) => [asc(fields.createdAt)], + with: { + phaseContents: { + where: (table, { isNull }) => isNull(table.deletedAt), + orderBy: (fields, { asc }) => [asc(fields.createdAt)], + with: { + contentDraft: { + columns: { + contentMarkdown: false, + outline: false, + notes: false, + }, + }, + }, + }, + }, + }, + }, + }), + ); +} + +export async function createStrategy( + db: DB | DBTransaction, + values: typeof seoStrategyInsertSchema.infer, +) { + const result = await safe(() => + db.insert(schema.seoStrategy).values(values).returning(), + ); + if (!result.ok) { + return result; + } + const strategy = result.value[0]; + if (!strategy) { + return err(new Error("Failed to create strategy")); + } + return ok(strategy); +} + +export async function createStrategyPhase( + db: DB | DBTransaction, + values: typeof seoStrategyPhaseInsertSchema.infer, +) { + const result = await safe(() => + db.insert(schema.seoStrategyPhase).values(values).returning(), + ); + if (!result.ok) { + return result; + } + const phase = result.value[0]; + if (!phase) { + return err(new Error("Failed to create strategy phase")); + } + return ok(phase); +} + +export async function createStrategyPhaseContent( + db: DB | DBTransaction, + values: typeof seoStrategyPhaseContentInsertSchema.infer, +) { + const result = await safe(() => + db.insert(schema.seoStrategyPhaseContent).values(values).returning(), + ); + if (!result.ok) { + return result; + } + const content = result.value[0]; + if (!content) { + return err(new Error("Failed to create strategy phase content")); + } + return ok(content); +} + +export async function updateStrategyPhase( + db: DB | DBTransaction, + values: typeof seoStrategyPhaseUpdateSchema.infer, +) { + const result = await safe(() => + db + .update(schema.seoStrategyPhase) + .set(values) + .where( + and( + eq(schema.seoStrategyPhase.id, values.id), + eq(schema.seoStrategyPhase.strategyId, values.strategyId), + ), + ) + .returning(), + ); + if (!result.ok) { + return result; + } + const phase = result.value[0]; + if (!phase) { + return err(new Error("Failed to update strategy phase")); + } + return ok(phase); +} diff --git a/packages/db/src/schema/seo/content-draft-schema.ts b/packages/db/src/schema/seo/content-draft-schema.ts index bb87fb75..55d5de44 100644 --- a/packages/db/src/schema/seo/content-draft-schema.ts +++ b/packages/db/src/schema/seo/content-draft-schema.ts @@ -17,6 +17,9 @@ import { seoContentDraftChat } from "./content-draft-chat-schema"; import { seoContentDraftUser } from "./content-draft-user-schema"; import { seoContent } from "./content-schema"; import { seoProject } from "./project-schema"; +import { seoStrategyPhaseContent } from "./strategy-phase-content-schema"; +import { seoStrategy } from "./strategy-schema"; +import { seoStrategySnapshot } from "./strategy-snapshot-schema"; import { seoTaskRun } from "./task-run-schema"; /** @@ -45,6 +48,10 @@ export const seoContentDraft = pgSeoTable( onDelete: "cascade", onUpdate: "cascade", }), + strategyId: uuid().references(() => seoStrategy.id, { + onDelete: "set null", + onUpdate: "cascade", + }), // Content identification slug: text().notNull(), @@ -116,6 +123,10 @@ export const seoContentDraftRelations = relations( fields: [seoContentDraft.baseContentId], references: [seoContent.id], }), + strategy: one(seoStrategy, { + fields: [seoContentDraft.strategyId], + references: [seoStrategy.id], + }), outlineTask: one(seoTaskRun, { fields: [seoContentDraft.outlineGeneratedByTaskRunId], references: [seoTaskRun.id], @@ -124,6 +135,8 @@ export const seoContentDraftRelations = relations( fields: [seoContentDraft.generatedByTaskRunId], references: [seoTaskRun.id], }), + snapshots: many(seoStrategySnapshot), + phaseContent: many(seoStrategyPhaseContent), // Attribution join tables contributingChatsMap: many(seoContentDraftChat), contributorsMap: many(seoContentDraftUser), diff --git a/packages/db/src/schema/seo/index.ts b/packages/db/src/schema/seo/index.ts index 84be3474..37daf746 100644 --- a/packages/db/src/schema/seo/index.ts +++ b/packages/db/src/schema/seo/index.ts @@ -9,4 +9,9 @@ export * from "./content-user-schema"; export * from "./integration-schema"; export * from "./project-author-schema"; export * from "./project-schema"; +export * from "./strategy-phase-content-schema"; +export * from "./strategy-phase-schema"; +export * from "./strategy-schema"; +export * from "./strategy-snapshot-content-schema"; +export * from "./strategy-snapshot-schema"; export * from "./task-run-schema"; diff --git a/packages/db/src/schema/seo/strategy-phase-content-schema.ts b/packages/db/src/schema/seo/strategy-phase-content-schema.ts new file mode 100644 index 00000000..4650b96b --- /dev/null +++ b/packages/db/src/schema/seo/strategy-phase-content-schema.ts @@ -0,0 +1,74 @@ +import { + contentStrategyPhaseActions, + contentStrategyPhaseRoles, +} from "@rectangular-labs/core/schemas/strategy-parsers"; +import { type } from "arktype"; +import { + createInsertSchema, + createSelectSchema, + createUpdateSchema, +} from "drizzle-arktype"; +import { relations } from "drizzle-orm"; +import { index, text, uuid } from "drizzle-orm/pg-core"; +import { timestamps, uuidv7 } from "../_helper"; +import { pgSeoTable } from "../_table"; +import { seoContentDraft } from "./content-draft-schema"; +import { seoStrategyPhase } from "./strategy-phase-schema"; + +export const seoStrategyPhaseContent = pgSeoTable( + "strategy_phase_content", + { + id: uuid().primaryKey().$defaultFn(uuidv7), + phaseId: uuid() + .notNull() + .references(() => seoStrategyPhase.id, { + onDelete: "cascade", + onUpdate: "cascade", + }), + contentDraftId: uuid().references(() => seoContentDraft.id, { + onDelete: "set null", + onUpdate: "cascade", + }), + action: text({ enum: contentStrategyPhaseActions }).notNull(), + plannedTitle: text(), + plannedPrimaryKeyword: text(), + role: text({ enum: contentStrategyPhaseRoles }), + notes: text(), + ...timestamps, + }, + (table) => [ + index("seo_strategy_phase_content_phase_idx").on(table.phaseId), + index("seo_strategy_phase_content_draft_idx").on(table.contentDraftId), + ], +); + +export const seoStrategyPhaseContentRelations = relations( + seoStrategyPhaseContent, + ({ one }) => ({ + phase: one(seoStrategyPhase, { + fields: [seoStrategyPhaseContent.phaseId], + references: [seoStrategyPhase.id], + }), + contentDraft: one(seoContentDraft, { + fields: [seoStrategyPhaseContent.contentDraftId], + references: [seoContentDraft.id], + }), + }), +); + +export const seoStrategyPhaseContentInsertSchema = createInsertSchema( + seoStrategyPhaseContent, +).omit("id", "createdAt", "updatedAt", "deletedAt"); +export const seoStrategyPhaseContentSelectSchema = createSelectSchema( + seoStrategyPhaseContent, +); +export const seoStrategyPhaseContentUpdateSchema = createUpdateSchema( + seoStrategyPhaseContent, +) + .omit("createdAt", "updatedAt", "deletedAt") + .merge( + type({ + id: "string.uuid", + phaseId: "string.uuid", + }), + ); diff --git a/packages/db/src/schema/seo/strategy-phase-schema.ts b/packages/db/src/schema/seo/strategy-phase-schema.ts new file mode 100644 index 00000000..1064e41c --- /dev/null +++ b/packages/db/src/schema/seo/strategy-phase-schema.ts @@ -0,0 +1,85 @@ +import type { publishingSettingsSchema } from "@rectangular-labs/core/schemas/project-parsers"; +import { + strategyPhaseStatuses, + strategyPhaseTypes, +} from "@rectangular-labs/core/schemas/strategy-parsers"; +import { type } from "arktype"; +import { + createInsertSchema, + createSelectSchema, + createUpdateSchema, +} from "drizzle-arktype"; +import { isNull, relations } from "drizzle-orm"; +import { + index, + integer, + jsonb, + text, + timestamp, + uuid, +} from "drizzle-orm/pg-core"; +import { timestamps, uuidv7 } from "../_helper"; +import { pgSeoTable } from "../_table"; +import { seoStrategyPhaseContent } from "./strategy-phase-content-schema"; +import { seoStrategy } from "./strategy-schema"; +import { seoStrategySnapshot } from "./strategy-snapshot-schema"; + +export const seoStrategyPhase = pgSeoTable( + "strategy_phase", + { + id: uuid().primaryKey().$defaultFn(uuidv7), + strategyId: uuid() + .notNull() + .references(() => seoStrategy.id, { + onDelete: "cascade", + onUpdate: "cascade", + }), + type: text({ enum: strategyPhaseTypes }).notNull(), + name: text().notNull(), + observationWeeks: integer().notNull().default(0), + successCriteria: text().notNull(), + cadence: jsonb() + .$type<(typeof publishingSettingsSchema.infer)["cadence"]>() + .notNull(), + targetCompletionDate: timestamp({ mode: "date", withTimezone: true }), + status: text({ enum: strategyPhaseStatuses }) + .notNull() + .default("suggestion"), + startedAt: timestamp({ mode: "date", withTimezone: true }), + completedAt: timestamp({ mode: "date", withTimezone: true }), + observationEndsAt: timestamp({ mode: "date", withTimezone: true }), + ...timestamps, + }, + (table) => [ + index("seo_strategy_phase_strategy_created_at_idx") + .on(table.strategyId, table.createdAt) + .where(isNull(table.deletedAt)), + index("seo_strategy_phase_status_idx").on(table.status), + ], +); + +export const seoStrategyPhaseRelations = relations( + seoStrategyPhase, + ({ one, many }) => ({ + strategy: one(seoStrategy, { + fields: [seoStrategyPhase.strategyId], + references: [seoStrategy.id], + }), + phaseContents: many(seoStrategyPhaseContent), + snapshots: many(seoStrategySnapshot), + }), +); + +export const seoStrategyPhaseInsertSchema = createInsertSchema( + seoStrategyPhase, +).omit("id", "createdAt", "updatedAt", "deletedAt"); +export const seoStrategyPhaseSelectSchema = + createSelectSchema(seoStrategyPhase); +export const seoStrategyPhaseUpdateSchema = createUpdateSchema(seoStrategyPhase) + .omit("createdAt", "updatedAt", "deletedAt") + .merge( + type({ + id: "string.uuid", + strategyId: "string.uuid", + }), + ); diff --git a/packages/db/src/schema/seo/strategy-schema.ts b/packages/db/src/schema/seo/strategy-schema.ts new file mode 100644 index 00000000..806bd45d --- /dev/null +++ b/packages/db/src/schema/seo/strategy-schema.ts @@ -0,0 +1,67 @@ +import { + type strategyGoalSchema, + strategyStatuses, +} from "@rectangular-labs/core/schemas/strategy-parsers"; +import { type } from "arktype"; +import { + createInsertSchema, + createSelectSchema, + createUpdateSchema, +} from "drizzle-arktype"; +import { isNull, relations } from "drizzle-orm"; +import { index, jsonb, text, uuid } from "drizzle-orm/pg-core"; +import { timestamps, uuidv7 } from "../_helper"; +import { pgSeoTable } from "../_table"; +import { seoProject } from "./project-schema"; +import { seoStrategyPhase } from "./strategy-phase-schema"; +import { seoStrategySnapshot } from "./strategy-snapshot-schema"; + +export const seoStrategy = pgSeoTable( + "strategy", + { + id: uuid().primaryKey().$defaultFn(uuidv7), + projectId: uuid() + .notNull() + .references(() => seoProject.id, { + onDelete: "cascade", + onUpdate: "cascade", + }), + name: text().notNull(), + description: text(), + motivation: text().notNull(), + goal: jsonb().$type().notNull(), + status: text({ enum: strategyStatuses }).notNull().default("suggestion"), + ...timestamps, + }, + (table) => [ + index("seo_strategy_project_updated_at_idx") + .on(table.projectId, table.updatedAt) + .where(isNull(table.deletedAt)), + index("seo_strategy_status_idx").on(table.status), + ], +); + +export const seoStrategyRelations = relations(seoStrategy, ({ one, many }) => ({ + project: one(seoProject, { + fields: [seoStrategy.projectId], + references: [seoProject.id], + }), + phases: many(seoStrategyPhase), + snapshots: many(seoStrategySnapshot), +})); + +export const seoStrategyInsertSchema = createInsertSchema(seoStrategy).omit( + "id", + "createdAt", + "updatedAt", + "deletedAt", +); +export const seoStrategySelectSchema = createSelectSchema(seoStrategy); +export const seoStrategyUpdateSchema = createUpdateSchema(seoStrategy) + .omit("createdAt", "updatedAt", "deletedAt") + .merge( + type({ + id: "string.uuid", + projectId: "string.uuid", + }), + ); diff --git a/packages/db/src/schema/seo/strategy-snapshot-content-schema.ts b/packages/db/src/schema/seo/strategy-snapshot-content-schema.ts new file mode 100644 index 00000000..e9bc2386 --- /dev/null +++ b/packages/db/src/schema/seo/strategy-snapshot-content-schema.ts @@ -0,0 +1,73 @@ +import type { KeywordSnapshot } from "@rectangular-labs/core/schemas/strategy-parsers"; +import { type } from "arktype"; +import { + createInsertSchema, + createSelectSchema, + createUpdateSchema, +} from "drizzle-arktype"; +import { relations } from "drizzle-orm"; +import { index, integer, jsonb, real, uuid } from "drizzle-orm/pg-core"; +import { timestamps, uuidv7 } from "../_helper"; +import { pgSeoTable } from "../_table"; +import { seoContentDraft } from "./content-draft-schema"; +import { seoStrategySnapshot } from "./strategy-snapshot-schema"; + +export const seoStrategySnapshotContent = pgSeoTable( + "strategy_snapshot_content", + { + id: uuid().primaryKey().$defaultFn(uuidv7), + snapshotId: uuid() + .notNull() + .references(() => seoStrategySnapshot.id, { + onDelete: "cascade", + onUpdate: "cascade", + }), + contentDraftId: uuid() + .notNull() + .references(() => seoContentDraft.id, { + onDelete: "cascade", + onUpdate: "cascade", + }), + clicks: integer().notNull().default(0), + impressions: integer().notNull().default(0), + avgPosition: real().notNull().default(0), + conversions: integer(), + topKeywords: jsonb().$type().notNull(), + ...timestamps, + }, + (table) => [ + index("seo_strategy_snapshot_content_snapshot_idx").on(table.snapshotId), + index("seo_strategy_snapshot_content_draft_idx").on(table.contentDraftId), + ], +); + +export const seoStrategySnapshotContentRelations = relations( + seoStrategySnapshotContent, + ({ one }) => ({ + snapshot: one(seoStrategySnapshot, { + fields: [seoStrategySnapshotContent.snapshotId], + references: [seoStrategySnapshot.id], + }), + contentDraft: one(seoContentDraft, { + fields: [seoStrategySnapshotContent.contentDraftId], + references: [seoContentDraft.id], + }), + }), +); + +export const seoStrategySnapshotContentInsertSchema = createInsertSchema( + seoStrategySnapshotContent, +).omit("id", "createdAt", "updatedAt", "deletedAt"); +export const seoStrategySnapshotContentSelectSchema = createSelectSchema( + seoStrategySnapshotContent, +); +export const seoStrategySnapshotContentUpdateSchema = createUpdateSchema( + seoStrategySnapshotContent, +) + .omit("createdAt", "updatedAt", "deletedAt") + .merge( + type({ + id: "string.uuid", + snapshotId: "string.uuid", + }), + ); diff --git a/packages/db/src/schema/seo/strategy-snapshot-schema.ts b/packages/db/src/schema/seo/strategy-snapshot-schema.ts new file mode 100644 index 00000000..66ef27d8 --- /dev/null +++ b/packages/db/src/schema/seo/strategy-snapshot-schema.ts @@ -0,0 +1,77 @@ +import { + type SnapshotAggregate, + type SnapshotDelta, + strategySnapshotTriggers, +} from "@rectangular-labs/core/schemas/strategy-parsers"; +import { type } from "arktype"; +import { + createInsertSchema, + createSelectSchema, + createUpdateSchema, +} from "drizzle-arktype"; +import { relations } from "drizzle-orm"; +import { index, jsonb, text, timestamp, uuid } from "drizzle-orm/pg-core"; +import { timestamps, uuidv7 } from "../_helper"; +import { pgSeoTable } from "../_table"; +import { seoStrategyPhase } from "./strategy-phase-schema"; +import { seoStrategy } from "./strategy-schema"; +import { seoStrategySnapshotContent } from "./strategy-snapshot-content-schema"; + +export const seoStrategySnapshot = pgSeoTable( + "strategy_snapshot", + { + id: uuid().primaryKey().$defaultFn(uuidv7), + strategyId: uuid() + .notNull() + .references(() => seoStrategy.id, { + onDelete: "cascade", + onUpdate: "cascade", + }), + phaseId: uuid().references(() => seoStrategyPhase.id, { + onDelete: "set null", + onUpdate: "cascade", + }), + takenAt: timestamp({ mode: "date", withTimezone: true }).notNull(), + triggerType: text({ enum: strategySnapshotTriggers }).notNull(), + aggregate: jsonb().$type().notNull(), + delta: jsonb().$type(), + aiInsight: text(), + ...timestamps, + }, + (table) => [ + index("seo_strategy_snapshot_strategy_idx").on(table.strategyId), + index("seo_strategy_snapshot_phase_idx").on(table.phaseId), + index("seo_strategy_snapshot_taken_at_idx").on(table.takenAt), + ], +); + +export const seoStrategySnapshotRelations = relations( + seoStrategySnapshot, + ({ one, many }) => ({ + strategy: one(seoStrategy, { + fields: [seoStrategySnapshot.strategyId], + references: [seoStrategy.id], + }), + phase: one(seoStrategyPhase, { + fields: [seoStrategySnapshot.phaseId], + references: [seoStrategyPhase.id], + }), + contents: many(seoStrategySnapshotContent), + }), +); + +export const seoStrategySnapshotInsertSchema = createInsertSchema( + seoStrategySnapshot, +).omit("id", "createdAt", "updatedAt", "deletedAt"); +export const seoStrategySnapshotSelectSchema = + createSelectSchema(seoStrategySnapshot); +export const seoStrategySnapshotUpdateSchema = createUpdateSchema( + seoStrategySnapshot, +) + .omit("createdAt", "updatedAt", "deletedAt") + .merge( + type({ + id: "string.uuid", + strategyId: "string.uuid", + }), + ); From e8b0863cc730d93346039a75d666b3802b59733c Mon Sep 17 00:00:00 2001 From: ElasticBottle Date: Fri, 30 Jan 2026 05:46:44 -0600 Subject: [PATCH 02/60] feat(api-seo): add api routes for managing strategy --- packages/api-seo/src/routes/index.ts | 1 + packages/api-seo/src/routes/strategy.ts | 282 ++++++++++++++++++++++++ 2 files changed, 283 insertions(+) create mode 100644 packages/api-seo/src/routes/strategy.ts diff --git a/packages/api-seo/src/routes/index.ts b/packages/api-seo/src/routes/index.ts index d5faf590..cd3b7195 100644 --- a/packages/api-seo/src/routes/index.ts +++ b/packages/api-seo/src/routes/index.ts @@ -5,6 +5,7 @@ export const router = { chat: lazy(() => import("./chat")), task: lazy(() => import("./task")), content: lazy(() => import("./content")), + strategy: lazy(() => import("./strategy")), integrations: lazy(() => import("./integration")), auth: { session: lazy(() => import("./auth/session")), diff --git a/packages/api-seo/src/routes/strategy.ts b/packages/api-seo/src/routes/strategy.ts new file mode 100644 index 00000000..1ee63723 --- /dev/null +++ b/packages/api-seo/src/routes/strategy.ts @@ -0,0 +1,282 @@ +import { ORPCError } from "@orpc/server"; +import { schema } from "@rectangular-labs/db"; +import { + createContentDraft, + createStrategy, + createStrategyPhase, + createStrategyPhaseContent, + getStrategyDetails, + listStrategiesByProjectId, + updateStrategyPhase, +} from "@rectangular-labs/db/operations"; +import { type } from "arktype"; +import { base, withOrganizationIdBase } from "../context"; +import { validateOrganizationMiddleware } from "../lib/middleware/validate-organization"; + +const contentDraftInputSchema = schema.seoContentDraftInsertSchema.omit( + "organizationId", + "projectId", + "strategyId", +); + +const phaseContentInputSchema = type({ + action: "'create'|'improve'|'expand'", + "contentDraftId?": "string.uuid|undefined", + "contentDraft?": contentDraftInputSchema.or(type.undefined), + "plannedTitle?": "string|undefined", + "plannedPrimaryKeyword?": "string|undefined", + "role?": "'pillar'|'supporting'|undefined", + "notes?": "string|undefined", +}); + +const list = withOrganizationIdBase + .route({ method: "GET", path: "/" }) + .input( + type({ + organizationIdentifier: "string", + projectId: "string.uuid", + }), + ) + .use(validateOrganizationMiddleware, (input) => input.organizationIdentifier) + .output( + type({ + data: type({ + "...": schema.seoStrategySelectSchema, + phases: schema.seoStrategyPhaseSelectSchema.array(), + }).array(), + }), + ) + .handler(async ({ context, input }) => { + const strategiesResult = await listStrategiesByProjectId({ + db: context.db, + projectId: input.projectId, + }); + if (!strategiesResult.ok) { + throw new ORPCError("INTERNAL_SERVER_ERROR", { + message: "Failed to load strategies.", + cause: strategiesResult.error, + }); + } + + const data = strategiesResult.value.map((strategy) => { + const phases = strategy.phases ?? []; + const currentPhase = + phases.find((phase) => phase.status === "in_progress") ?? + phases.find((phase) => phase.status === "planned") ?? + phases.find((phase) => phase.status === "observing") ?? + phases.at(0) ?? + null; + return { + ...strategy, + currentPhase, + }; + }); + + return { data }; + }); + +const get = withOrganizationIdBase + .route({ method: "GET", path: "/{id}" }) + .input( + type({ + organizationIdentifier: "string", + projectId: "string.uuid", + id: "string.uuid", + }), + ) + .use(validateOrganizationMiddleware, (input) => input.organizationIdentifier) + .output( + type({ + "...": schema.seoStrategySelectSchema, + phases: type({ + "...": schema.seoStrategyPhaseSelectSchema, + phaseContents: type({ + "...": schema.seoStrategyPhaseContentSelectSchema, + contentDraft: schema.seoContentDraftSelectSchema + .omit("contentMarkdown", "outline", "notes") + .or(type.null), + }).array(), + }).array(), + }), + ) + .handler(async ({ context, input }) => { + const strategyResult = await getStrategyDetails({ + db: context.db, + projectId: input.projectId, + strategyId: input.id, + }); + if (!strategyResult.ok) { + throw new ORPCError("INTERNAL_SERVER_ERROR", { + message: "Failed to load strategy.", + cause: strategyResult.error, + }); + } + if (!strategyResult.value) { + throw new ORPCError("NOT_FOUND", { + message: "No strategy found.", + }); + } + return strategyResult.value; + }); + +const create = withOrganizationIdBase + .route({ method: "POST", path: "/" }) + .input( + type({ + organizationIdentifier: "string", + projectId: "string.uuid", + strategy: schema.seoStrategyInsertSchema.omit("projectId"), + phase: schema.seoStrategyPhaseInsertSchema.omit("strategyId"), + "contents?": phaseContentInputSchema.array(), + }), + ) + .use(validateOrganizationMiddleware, (input) => input.organizationIdentifier) + .output( + type({ + message: "string", + }), + ) + .handler(async ({ context, input }) => { + const contents = input.contents ?? []; + const strategyId = await context.db.transaction(async (tx) => { + const strategyResult = await createStrategy(tx, { + ...input.strategy, + projectId: input.projectId, + }); + if (!strategyResult.ok) { + throw new ORPCError("INTERNAL_SERVER_ERROR", { + message: "Failed to create strategy.", + cause: strategyResult.error, + }); + } + + const phaseResult = await createStrategyPhase(tx, { + ...input.phase, + strategyId: strategyResult.value.id, + }); + if (!phaseResult.ok) { + throw new ORPCError("INTERNAL_SERVER_ERROR", { + message: "Failed to create strategy phase.", + cause: phaseResult.error, + }); + } + + for (const content of contents) { + let contentDraftId = content.contentDraftId; + if (!contentDraftId && content.contentDraft) { + const draftResult = await createContentDraft(tx, { + ...content.contentDraft, + organizationId: context.organization.id, + projectId: input.projectId, + strategyId: strategyResult.value.id, + }); + if (!draftResult.ok) { + throw new ORPCError("INTERNAL_SERVER_ERROR", { + message: "Failed to create content draft.", + cause: draftResult.error, + }); + } + contentDraftId = draftResult.value.id; + } + + const phaseContentResult = await createStrategyPhaseContent(tx, { + phaseId: phaseResult.value.id, + contentDraftId, + action: content.action, + plannedTitle: content.plannedTitle ?? content.contentDraft?.title, + plannedPrimaryKeyword: + content.plannedPrimaryKeyword ?? + content.contentDraft?.primaryKeyword, + role: content.role, + notes: content.notes, + }); + if (!phaseContentResult.ok) { + throw new ORPCError("INTERNAL_SERVER_ERROR", { + message: "Failed to create strategy phase content.", + cause: phaseContentResult.error, + }); + } + } + + return strategyResult.value.id; + }); + + const strategyResult = await getStrategyDetails({ + db: context.db, + projectId: input.projectId, + strategyId, + }); + if (!strategyResult.ok) { + throw new ORPCError("INTERNAL_SERVER_ERROR", { + message: "Failed to load strategy.", + cause: strategyResult.error, + }); + } + if (!strategyResult.value) { + throw new ORPCError("NOT_FOUND", { + message: "No strategy found after creation.", + }); + } + return strategyResult.value; + }); + +const createPhase = withOrganizationIdBase + .route({ method: "POST", path: "/{strategyId}/phases" }) + .input( + type({ + organizationIdentifier: "string", + projectId: "string.uuid", + strategyId: "string.uuid", + phase: schema.seoStrategyPhaseInsertSchema.omit("strategyId"), + }), + ) + .use(validateOrganizationMiddleware, (input) => input.organizationIdentifier) + .output(schema.seoStrategyPhaseSelectSchema) + .handler(async ({ context, input }) => { + const phaseResult = await createStrategyPhase(context.db, { + ...input.phase, + strategyId: input.strategyId, + }); + if (!phaseResult.ok) { + throw new ORPCError("INTERNAL_SERVER_ERROR", { + message: "Failed to create phase.", + cause: phaseResult.error, + }); + } + return phaseResult.value; + }); + +const updatePhase = withOrganizationIdBase + .route({ method: "PATCH", path: "/phases/{id}" }) + .input( + schema.seoStrategyPhaseUpdateSchema.merge( + type({ + organizationIdentifier: "string", + projectId: "string.uuid", + }), + ), + ) + .use(validateOrganizationMiddleware, (input) => input.organizationIdentifier) + .output(schema.seoStrategyPhaseSelectSchema) + .handler(async ({ context, input }) => { + const updateResult = await updateStrategyPhase(context.db, input); + if (!updateResult.ok) { + throw new ORPCError("INTERNAL_SERVER_ERROR", { + message: "Failed to update phase.", + cause: updateResult.error, + }); + } + return updateResult.value; + }); + +export default base + .prefix("/organization/{organizationIdentifier}/project/{projectId}/strategy") + .router({ + list, + get, + create, + phases: { + create: createPhase, + update: updatePhase, + }, + }); From c31815b598d7995c9aee9ce9be62360421ca37b3 Mon Sep 17 00:00:00 2001 From: ElasticBottle Date: Fri, 30 Jan 2026 22:31:13 +0900 Subject: [PATCH 03/60] feat(db): remove the content attribution --- packages/api-seo/src/routes/content.ts | 52 ------------ packages/db/src/operations/index.ts | 1 - .../src/operations/seo/content-attribution.ts | 85 ------------------- packages/db/src/schema/seo/chat-schema.ts | 2 - .../db/src/schema/seo/content-chat-schema.ts | 50 ----------- packages/db/src/schema/seo/content-schema.ts | 6 +- .../db/src/schema/seo/content-user-schema.ts | 50 ----------- packages/db/src/schema/seo/index.ts | 2 - 8 files changed, 1 insertion(+), 247 deletions(-) delete mode 100644 packages/db/src/operations/seo/content-attribution.ts delete mode 100644 packages/db/src/schema/seo/content-chat-schema.ts delete mode 100644 packages/db/src/schema/seo/content-user-schema.ts diff --git a/packages/api-seo/src/routes/content.ts b/packages/api-seo/src/routes/content.ts index 82a5cb76..e9a5f947 100644 --- a/packages/api-seo/src/routes/content.ts +++ b/packages/api-seo/src/routes/content.ts @@ -5,13 +5,9 @@ import { } from "@rectangular-labs/core/schemas/content-parsers"; import { schema } from "@rectangular-labs/db"; import { - addContentChatContribution, - addContentUserContribution, countDraftsByStatus, createContent, getDraftById, - getDraftContributingChats, - getDraftContributors, getNextVersionForSlug, getSeoProjectByIdentifierAndOrgId, hardDeleteDraft, @@ -576,54 +572,6 @@ const publishContent = base }); } - const [draftChatsResult, draftUsersResult] = await Promise.all([ - getDraftContributingChats({ - db: context.db, - draftId: draft.id, - }), - getDraftContributors({ - db: context.db, - draftId: draft.id, - }), - ]); - if (!draftChatsResult.ok) { - throw new ORPCError("INTERNAL_SERVER_ERROR", { - message: "Failed to load draft chat attribution.", - cause: draftChatsResult.error, - }); - } - if (!draftUsersResult.ok) { - throw new ORPCError("INTERNAL_SERVER_ERROR", { - message: "Failed to load draft contributor attribution.", - cause: draftUsersResult.error, - }); - } - - const [chatAttributionResult, userAttributionResult] = await Promise.all([ - addContentChatContribution({ - db: context.db, - contentId: createdContent.id, - chatIds: draftChatsResult.value.map((entry) => entry.chatId), - }), - addContentUserContribution({ - db: context.db, - contentId: createdContent.id, - userIds: draftUsersResult.value.map((entry) => entry.userId), - }), - ]); - if (!chatAttributionResult.ok) { - throw new ORPCError("INTERNAL_SERVER_ERROR", { - message: "Failed to record published content chat attribution.", - cause: chatAttributionResult.error, - }); - } - if (!userAttributionResult.ok) { - throw new ORPCError("INTERNAL_SERVER_ERROR", { - message: "Failed to record published content user attribution.", - cause: userAttributionResult.error, - }); - } - const deletedDraftResult = await hardDeleteDraft({ db: context.db, organizationId: draft.organizationId, diff --git a/packages/db/src/operations/index.ts b/packages/db/src/operations/index.ts index 684e8b43..c570edc2 100644 --- a/packages/db/src/operations/index.ts +++ b/packages/db/src/operations/index.ts @@ -1,6 +1,5 @@ export * from "./seo/chat-message-operations"; export * from "./seo/chat-operations"; -export * from "./seo/content-attribution"; export * from "./seo/content-draft-attribution"; export * from "./seo/content-operations"; export * from "./seo/integration-operations"; diff --git a/packages/db/src/operations/seo/content-attribution.ts b/packages/db/src/operations/seo/content-attribution.ts deleted file mode 100644 index 616bb4d9..00000000 --- a/packages/db/src/operations/seo/content-attribution.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { ok, safe } from "@rectangular-labs/result"; -import { type DB, schema } from "../../client"; - -/** - * Record that a chat contributed to published content. - */ -export async function addContentChatContribution(args: { - db: DB; - contentId: string; - chatIds: string[]; -}) { - if (args.chatIds.length === 0) { - return ok([]); - } - return await safe(() => - args.db - .insert(schema.seoContentChat) - .values( - args.chatIds.map((chatId) => ({ contentId: args.contentId, chatId })), - ) - .onConflictDoNothing() - .returning(), - ); -} - -/** - * Record that a user contributed to published content. - */ -export async function addContentUserContribution(args: { - db: DB; - contentId: string; - userIds: string[]; -}) { - if (args.userIds.length === 0) { - return ok([]); - } - return await safe(() => - args.db - .insert(schema.seoContentUser) - .values( - args.userIds.map((userId) => ({ - contentId: args.contentId, - userId, - })), - ) - .onConflictDoNothing() - .returning(), - ); -} - -/** - * Get all chats that contributed to published content. - */ -export async function getContentContributingChats(args: { - db: DB; - contentId: string; -}) { - return await safe(() => - args.db.query.seoContentChat.findMany({ - where: (table, { eq }) => eq(table.contentId, args.contentId), - with: { - chat: true, - }, - orderBy: (fields, { desc }) => [desc(fields.contributedAt)], - }), - ); -} - -/** - * Get all users that contributed to published content. - */ -export async function getContentContributors(args: { - db: DB; - contentId: string; -}) { - return await safe(() => - args.db.query.seoContentUser.findMany({ - where: (table, { eq }) => eq(table.contentId, args.contentId), - with: { - user: true, - }, - orderBy: (fields, { desc }) => [desc(fields.contributedAt)], - }), - ); -} diff --git a/packages/db/src/schema/seo/chat-schema.ts b/packages/db/src/schema/seo/chat-schema.ts index 4585fc55..9c8b6e8d 100644 --- a/packages/db/src/schema/seo/chat-schema.ts +++ b/packages/db/src/schema/seo/chat-schema.ts @@ -14,7 +14,6 @@ import { timestamps, uuidv7 } from "../_helper"; import { pgSeoTable } from "../_table"; import { organization, user } from "../auth-schema"; import { seoChatMessage } from "./chat-message-schema"; -import { seoContentChat } from "./content-chat-schema"; import { seoContentDraftChat } from "./content-draft-chat-schema"; import { seoProject } from "./project-schema"; @@ -76,7 +75,6 @@ export const seoChatRelations = relations(seoChat, ({ one, many }) => ({ }), messages: many(seoChatMessage), contentDraftMap: many(seoContentDraftChat), - contentMap: many(seoContentChat), })); export const seoChatInsertSchema = createInsertSchema(seoChat).omit( diff --git a/packages/db/src/schema/seo/content-chat-schema.ts b/packages/db/src/schema/seo/content-chat-schema.ts deleted file mode 100644 index be40e1d8..00000000 --- a/packages/db/src/schema/seo/content-chat-schema.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { createInsertSchema, createSelectSchema } from "drizzle-arktype"; -import { relations } from "drizzle-orm"; -import { index, primaryKey, timestamp, uuid } from "drizzle-orm/pg-core"; -import { pgSeoTable } from "../_table"; -import { seoChat } from "./chat-schema"; -import { seoContent } from "./content-schema"; - -/** - * Tracks which chats contributed to published content. - * Append-only for attribution/audit purposes. - */ -export const seoContentChat = pgSeoTable( - "content_chat", - { - contentId: uuid() - .notNull() - .references(() => seoContent.id, { - onDelete: "cascade", - onUpdate: "cascade", - }), - chatId: uuid() - .notNull() - .references(() => seoChat.id, { - onDelete: "cascade", - onUpdate: "cascade", - }), - contributedAt: timestamp({ mode: "date", withTimezone: true }) - .notNull() - .defaultNow(), - }, - (table) => [ - primaryKey({ columns: [table.contentId, table.chatId] }), - index("seo_content_chat_content_idx").on(table.contentId), - index("seo_content_chat_chat_idx").on(table.chatId), - ], -); - -export const seoContentChatRelations = relations(seoContentChat, ({ one }) => ({ - content: one(seoContent, { - fields: [seoContentChat.contentId], - references: [seoContent.id], - }), - chat: one(seoChat, { - fields: [seoContentChat.chatId], - references: [seoChat.id], - }), -})); - -export const seoContentChatInsertSchema = createInsertSchema(seoContentChat); -export const seoContentChatSelectSchema = createSelectSchema(seoContentChat); diff --git a/packages/db/src/schema/seo/content-schema.ts b/packages/db/src/schema/seo/content-schema.ts index f235d5e5..51579f88 100644 --- a/packages/db/src/schema/seo/content-schema.ts +++ b/packages/db/src/schema/seo/content-schema.ts @@ -17,8 +17,6 @@ import { import { timestamps, uuidv7 } from "../_helper"; import { pgSeoTable } from "../_table"; import { organization } from "../auth-schema"; -import { seoContentChat } from "./content-chat-schema"; -import { seoContentUser } from "./content-user-schema"; import { seoProject } from "./project-schema"; /** @@ -85,7 +83,7 @@ export const seoContent = pgSeoTable( ], ); -export const seoContentRelations = relations(seoContent, ({ one, many }) => ({ +export const seoContentRelations = relations(seoContent, ({ one }) => ({ project: one(seoProject, { fields: [seoContent.projectId], references: [seoProject.id], @@ -94,8 +92,6 @@ export const seoContentRelations = relations(seoContent, ({ one, many }) => ({ fields: [seoContent.organizationId], references: [organization.id], }), - contributingChatsMap: many(seoContentChat), - contributorsMap: many(seoContentUser), })); export const seoContentInsertSchema = createInsertSchema(seoContent).omit( diff --git a/packages/db/src/schema/seo/content-user-schema.ts b/packages/db/src/schema/seo/content-user-schema.ts deleted file mode 100644 index df1a72a6..00000000 --- a/packages/db/src/schema/seo/content-user-schema.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { createInsertSchema, createSelectSchema } from "drizzle-arktype"; -import { relations } from "drizzle-orm"; -import { index, primaryKey, text, timestamp, uuid } from "drizzle-orm/pg-core"; -import { pgSeoTable } from "../_table"; -import { user } from "../auth-schema"; -import { seoContent } from "./content-schema"; - -/** - * Tracks which users contributed to published content. - * Append-only for attribution/audit purposes. - */ -export const seoContentUser = pgSeoTable( - "content_contributor", - { - contentId: uuid() - .notNull() - .references(() => seoContent.id, { - onDelete: "cascade", - onUpdate: "cascade", - }), - userId: text() - .notNull() - .references(() => user.id, { - onDelete: "cascade", - onUpdate: "cascade", - }), - contributedAt: timestamp({ mode: "date", withTimezone: true }) - .notNull() - .defaultNow(), - }, - (table) => [ - primaryKey({ columns: [table.contentId, table.userId] }), - index("seo_content_contributor_content_idx").on(table.contentId), - index("seo_content_contributor_user_idx").on(table.userId), - ], -); - -export const seoContentUserRelations = relations(seoContentUser, ({ one }) => ({ - content: one(seoContent, { - fields: [seoContentUser.contentId], - references: [seoContent.id], - }), - user: one(user, { - fields: [seoContentUser.userId], - references: [user.id], - }), -})); - -export const seoContentUserInsertSchema = createInsertSchema(seoContentUser); -export const seoContentUserSelectSchema = createSelectSchema(seoContentUser); diff --git a/packages/db/src/schema/seo/index.ts b/packages/db/src/schema/seo/index.ts index 37daf746..ec606611 100644 --- a/packages/db/src/schema/seo/index.ts +++ b/packages/db/src/schema/seo/index.ts @@ -1,11 +1,9 @@ export * from "./chat-message-schema"; export * from "./chat-schema"; -export * from "./content-chat-schema"; export * from "./content-draft-chat-schema"; export * from "./content-draft-schema"; export * from "./content-draft-user-schema"; export * from "./content-schema"; -export * from "./content-user-schema"; export * from "./integration-schema"; export * from "./project-author-schema"; export * from "./project-schema"; From 080e806efed6f726197f444e3ba2895aa96a1c3b Mon Sep 17 00:00:00 2001 From: ElasticBottle Date: Fri, 30 Jan 2026 23:27:18 +0900 Subject: [PATCH 04/60] chore(db): fix relation, add strategy update operation --- .../src/operations/seo/strategy-operations.ts | 27 +++++++++++++++++++ .../db/src/schema/seo/content-draft-schema.ts | 4 +-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/db/src/operations/seo/strategy-operations.ts b/packages/db/src/operations/seo/strategy-operations.ts index 70453a00..b1462b94 100644 --- a/packages/db/src/operations/seo/strategy-operations.ts +++ b/packages/db/src/operations/seo/strategy-operations.ts @@ -5,6 +5,7 @@ import type { seoStrategyPhaseContentInsertSchema, seoStrategyPhaseInsertSchema, seoStrategyPhaseUpdateSchema, + seoStrategyUpdateSchema, } from "../../schema/seo"; export async function listStrategiesByProjectId(args: { @@ -81,6 +82,32 @@ export async function createStrategy( return ok(strategy); } +export async function updateStrategy( + db: DB | DBTransaction, + values: typeof seoStrategyUpdateSchema.infer, +) { + const result = await safe(() => + db + .update(schema.seoStrategy) + .set(values) + .where( + and( + eq(schema.seoStrategy.id, values.id), + eq(schema.seoStrategy.projectId, values.projectId), + ), + ) + .returning(), + ); + if (!result.ok) { + return result; + } + const strategy = result.value[0]; + if (!strategy) { + return err(new Error("Failed to update strategy")); + } + return ok(strategy); +} + export async function createStrategyPhase( db: DB | DBTransaction, values: typeof seoStrategyPhaseInsertSchema.infer, diff --git a/packages/db/src/schema/seo/content-draft-schema.ts b/packages/db/src/schema/seo/content-draft-schema.ts index 55d5de44..de455db2 100644 --- a/packages/db/src/schema/seo/content-draft-schema.ts +++ b/packages/db/src/schema/seo/content-draft-schema.ts @@ -19,7 +19,7 @@ import { seoContent } from "./content-schema"; import { seoProject } from "./project-schema"; import { seoStrategyPhaseContent } from "./strategy-phase-content-schema"; import { seoStrategy } from "./strategy-schema"; -import { seoStrategySnapshot } from "./strategy-snapshot-schema"; +import { seoStrategySnapshotContent } from "./strategy-snapshot-content-schema"; import { seoTaskRun } from "./task-run-schema"; /** @@ -135,7 +135,7 @@ export const seoContentDraftRelations = relations( fields: [seoContentDraft.generatedByTaskRunId], references: [seoTaskRun.id], }), - snapshots: many(seoStrategySnapshot), + metricSnapshot: many(seoStrategySnapshotContent), phaseContent: many(seoStrategyPhaseContent), // Attribution join tables contributingChatsMap: many(seoContentDraftChat), From f9a6a61067a9b8859c2c0216d144c08a98136ad1 Mon Sep 17 00:00:00 2001 From: ElasticBottle Date: Sat, 31 Jan 2026 07:30:14 +0900 Subject: [PATCH 05/60] chore(api-seo): remove data for seo tool reference --- packages/api-seo/src/lib/ai/tools/dataforseo-tool.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/api-seo/src/lib/ai/tools/dataforseo-tool.ts b/packages/api-seo/src/lib/ai/tools/dataforseo-tool.ts index 40607ab9..4d8e671e 100644 --- a/packages/api-seo/src/lib/ai/tools/dataforseo-tool.ts +++ b/packages/api-seo/src/lib/ai/tools/dataforseo-tool.ts @@ -383,15 +383,14 @@ export function createDataforseoToolWithMetadata( const toolDefinitions: AgentToolDefinition[] = [ { toolName: "get_ranked_keywords_for_site", - toolDescription: - "Fetch keywords a site currently ranks for (DataForSEO).", + toolDescription: "Fetch keywords a site currently ranks for.", toolInstruction: "Provide hostname without protocol. Use for profiling your site or competitors, and to discover ranking keyword clusters. Tune positionFrom/positionTo and limit/offset.", tool: getRankedKeywordsForSite, }, { toolName: "get_ranked_pages_for_site", - toolDescription: "Fetch ranked pages for a site (DataForSEO).", + toolDescription: "Fetch ranked pages for a site.", toolInstruction: "Provide hostname without protocol. Use to find top pages and infer content strategy and templates.", tool: getRankedPagesForSite, From 2e52fa63ab90d37d028e22a1e8bb0eda7b0c8be3 Mon Sep 17 00:00:00 2001 From: ElasticBottle Date: Sat, 31 Jan 2026 18:36:10 +0800 Subject: [PATCH 06/60] chore(db): update schema type names --- packages/core/src/schemas/strategy-parsers.ts | 101 ++++++++++++++++-- .../seo/strategy-phase-content-schema.ts | 8 +- .../src/schema/seo/strategy-phase-schema.ts | 16 ++- packages/db/src/schema/seo/strategy-schema.ts | 6 +- .../schema/seo/strategy-snapshot-schema.ts | 4 +- 5 files changed, 107 insertions(+), 28 deletions(-) diff --git a/packages/core/src/schemas/strategy-parsers.ts b/packages/core/src/schemas/strategy-parsers.ts index 52375d27..7d6b4516 100644 --- a/packages/core/src/schemas/strategy-parsers.ts +++ b/packages/core/src/schemas/strategy-parsers.ts @@ -1,12 +1,20 @@ import { type } from "arktype"; export const strategyGoalSchema = type({ - metric: "'conversions'|'clicks'|'impressions'|'avgPosition'", - target: "number", - timeframe: "'monthly'|'total'", -}).describe("Target metric for a strategy."); + metric: type("'conversions'|'clicks'|'impressions'|'avgPosition'").describe( + "Primary KPI to optimize for: conversions (outcomes), clicks (traffic), impressions (visibility), avgPosition (rank).", + ), + target: type("number").describe( + "Numeric goal for the chosen KPI (absolute count for conversions/clicks/impressions, or ranking position for avgPosition).", + ), + timeframe: type("'monthly'|'total'").describe( + "Whether the target is expected per month or cumulative across the full strategy duration.", + ), +}).describe( + "Target metric for a strategy. Example aggressive SEO goal: { metric: 'clicks', target: 120000, timeframe: 'monthly' }.", +); -export const strategyStatuses = [ +export const STRATEGY_STATUSES = [ "suggestion", "active", "observing", @@ -15,8 +23,78 @@ export const strategyStatuses = [ "dismissed", ] as const; -export const strategyPhaseTypes = ["build", "optimize", "expand"] as const; -export const strategyPhaseStatuses = [ +export const strategySuggestionSchema = type({ + name: type("string").describe("Short, clear strategy name."), + motivation: type("string").describe( + "Why this strategy matters right now, grounded in research or data.", + ), + "description?": type("string").describe( + "Concise description of what will be executed and how it works.", + ), + goal: strategyGoalSchema.describe( + "Primary success metric, target value, and timeframe. Should follow the SMART Goal setting", + ), +}).describe("Single strategy suggestion."); + +export const strategyPhaseTypeSchema = type( + "'build'|'optimize'|'expand'", +).describe("Strategy type that matches the strategy intent."); +export const contentStrategyActionSchema = type( + "'create'|'improve'|'expand'", +).describe("Content action for a strategy phase."); +export const cadenceSchema = type({ + // "daily" => frequency is articles/day + // "weekly" => frequency is articles/week + // "monthly" => frequency is articles/month + period: type("'daily' | 'weekly' | 'monthly'").describe( + "Publishing cadence unit.", + ), + frequency: type("number.integer >= 1").describe( + "Number of items published per period.", + ), + // Days that are eligible for publishing. Deselecting a day means we won't publish on that day. + allowedDays: type("'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat' | 'sun'") + .array() + .describe("Eligible days for publishing."), +}); +export const strategyPhaseSuggestionScheme = type({ + phase: type({ + type: strategyPhaseTypeSchema, + name: type("string").describe("Short phase name."), + observationWeeks: type("number").describe( + "Number of weeks to observe results after execution.", + ), + successCriteria: type("string").describe( + 'Observable criteria that define phase success. Should be the "SMAR" part of a SMART goal', + ), + cadence: cadenceSchema.describe("The "), + }).describe("Execution phase for the strategy."), + contents: type({ + action: contentStrategyActionSchema.describe( + "Action to take on the content item.", + ), + "plannedTitle?": type("string").describe( + "Working title for the planned content item.", + ), + "plannedPrimaryKeyword?": type("string").describe( + "Primary keyword targeted by the content item.", + ), + "role?": type("'pillar'|'supporting'").describe( + "Content role within the cluster.", + ), + "notes?": type("string").describe( + "Additional notes or constraints for the content item.", + ), + }) + .array() + .describe("Planned content items for the strategy."), +}); +export const STRATEGY_PHASE_TYPE = [ + "build", + "optimize", + "expand", +] as const satisfies (typeof strategyPhaseTypeSchema.infer)[]; +export const STRATEGY_PHASE_STATUSES = [ "suggestion", "planned", "in_progress", @@ -24,14 +102,15 @@ export const strategyPhaseStatuses = [ "completed", "dismissed", ] as const; -export const contentStrategyPhaseActions = [ + +export const CONTENT_STRATEGY_ACTION = [ "create", "improve", "expand", -] as const; -export const contentStrategyPhaseRoles = ["pillar", "supporting"] as const; +] as const satisfies (typeof contentStrategyActionSchema.infer)[]; +export const CONTENT_ROLES = ["pillar", "supporting"] as const; -export const strategySnapshotTriggers = [ +export const STRATEGY_SNAPSHOT_TRIGGERS = [ "scheduled", "phase_complete", "manual", diff --git a/packages/db/src/schema/seo/strategy-phase-content-schema.ts b/packages/db/src/schema/seo/strategy-phase-content-schema.ts index 4650b96b..21d48a90 100644 --- a/packages/db/src/schema/seo/strategy-phase-content-schema.ts +++ b/packages/db/src/schema/seo/strategy-phase-content-schema.ts @@ -1,6 +1,6 @@ import { - contentStrategyPhaseActions, - contentStrategyPhaseRoles, + CONTENT_ROLES, + CONTENT_STRATEGY_ACTION, } from "@rectangular-labs/core/schemas/strategy-parsers"; import { type } from "arktype"; import { @@ -29,10 +29,10 @@ export const seoStrategyPhaseContent = pgSeoTable( onDelete: "set null", onUpdate: "cascade", }), - action: text({ enum: contentStrategyPhaseActions }).notNull(), + action: text({ enum: CONTENT_STRATEGY_ACTION }).notNull(), plannedTitle: text(), plannedPrimaryKeyword: text(), - role: text({ enum: contentStrategyPhaseRoles }), + role: text({ enum: CONTENT_ROLES }), notes: text(), ...timestamps, }, diff --git a/packages/db/src/schema/seo/strategy-phase-schema.ts b/packages/db/src/schema/seo/strategy-phase-schema.ts index 1064e41c..6d7cc841 100644 --- a/packages/db/src/schema/seo/strategy-phase-schema.ts +++ b/packages/db/src/schema/seo/strategy-phase-schema.ts @@ -1,7 +1,7 @@ -import type { publishingSettingsSchema } from "@rectangular-labs/core/schemas/project-parsers"; import { - strategyPhaseStatuses, - strategyPhaseTypes, + type cadenceSchema, + STRATEGY_PHASE_STATUSES, + STRATEGY_PHASE_TYPE, } from "@rectangular-labs/core/schemas/strategy-parsers"; import { type } from "arktype"; import { @@ -34,17 +34,15 @@ export const seoStrategyPhase = pgSeoTable( onDelete: "cascade", onUpdate: "cascade", }), - type: text({ enum: strategyPhaseTypes }).notNull(), + type: text({ enum: STRATEGY_PHASE_TYPE }).notNull(), name: text().notNull(), observationWeeks: integer().notNull().default(0), successCriteria: text().notNull(), - cadence: jsonb() - .$type<(typeof publishingSettingsSchema.infer)["cadence"]>() - .notNull(), - targetCompletionDate: timestamp({ mode: "date", withTimezone: true }), - status: text({ enum: strategyPhaseStatuses }) + cadence: jsonb().$type().notNull(), + status: text({ enum: STRATEGY_PHASE_STATUSES }) .notNull() .default("suggestion"), + targetCompletionDate: timestamp({ mode: "date", withTimezone: true }), startedAt: timestamp({ mode: "date", withTimezone: true }), completedAt: timestamp({ mode: "date", withTimezone: true }), observationEndsAt: timestamp({ mode: "date", withTimezone: true }), diff --git a/packages/db/src/schema/seo/strategy-schema.ts b/packages/db/src/schema/seo/strategy-schema.ts index 806bd45d..f5475dde 100644 --- a/packages/db/src/schema/seo/strategy-schema.ts +++ b/packages/db/src/schema/seo/strategy-schema.ts @@ -1,6 +1,6 @@ import { + STRATEGY_PHASE_STATUSES, type strategyGoalSchema, - strategyStatuses, } from "@rectangular-labs/core/schemas/strategy-parsers"; import { type } from "arktype"; import { @@ -30,7 +30,9 @@ export const seoStrategy = pgSeoTable( description: text(), motivation: text().notNull(), goal: jsonb().$type().notNull(), - status: text({ enum: strategyStatuses }).notNull().default("suggestion"), + status: text({ enum: STRATEGY_PHASE_STATUSES }) + .notNull() + .default("suggestion"), ...timestamps, }, (table) => [ diff --git a/packages/db/src/schema/seo/strategy-snapshot-schema.ts b/packages/db/src/schema/seo/strategy-snapshot-schema.ts index 66ef27d8..f87c8b8a 100644 --- a/packages/db/src/schema/seo/strategy-snapshot-schema.ts +++ b/packages/db/src/schema/seo/strategy-snapshot-schema.ts @@ -1,7 +1,7 @@ import { type SnapshotAggregate, type SnapshotDelta, - strategySnapshotTriggers, + STRATEGY_SNAPSHOT_TRIGGERS, } from "@rectangular-labs/core/schemas/strategy-parsers"; import { type } from "arktype"; import { @@ -32,7 +32,7 @@ export const seoStrategySnapshot = pgSeoTable( onUpdate: "cascade", }), takenAt: timestamp({ mode: "date", withTimezone: true }).notNull(), - triggerType: text({ enum: strategySnapshotTriggers }).notNull(), + triggerType: text({ enum: STRATEGY_SNAPSHOT_TRIGGERS }).notNull(), aggregate: jsonb().$type().notNull(), delta: jsonb().$type(), aiInsight: text(), From 96b5762abe588225ca626b5e15ee9672a307c96f Mon Sep 17 00:00:00 2001 From: ElasticBottle Date: Sun, 1 Feb 2026 11:18:54 +0800 Subject: [PATCH 07/60] feat(api-seo): add strategy suggestion workflow --- apps/seo/wrangler.jsonc | 15 + packages/api-seo/src/context.ts | 1 + packages/api-seo/src/lib/task.ts | 10 + packages/api-seo/src/routes/task.ts | 3 + packages/api-seo/src/types.ts | 2 + packages/api-seo/src/workflows/index.ts | 4 + .../src/workflows/onboarding-workflow.ts | 290 +++++++++++------- .../strategy-suggestions-workflow.ts | 226 ++++++++++++++ packages/core/src/schemas/task-parsers.ts | 19 +- .../src/operations/seo/project-operations.ts | 14 +- .../src/operations/seo/strategy-operations.ts | 12 +- 11 files changed, 479 insertions(+), 117 deletions(-) create mode 100644 packages/api-seo/src/workflows/strategy-suggestions-workflow.ts diff --git a/apps/seo/wrangler.jsonc b/apps/seo/wrangler.jsonc index 462a39c4..3ac7ea42 100644 --- a/apps/seo/wrangler.jsonc +++ b/apps/seo/wrangler.jsonc @@ -94,6 +94,11 @@ "name": "seo-onboarding", "binding": "SEO_ONBOARDING_WORKFLOW", "class_name": "SeoOnboardingWorkflow" + }, + { + "name": "seo-strategy-suggestions", + "binding": "SEO_STRATEGY_SUGGESTIONS_WORKFLOW", + "class_name": "SeoStrategySuggestionsWorkflow" } ], "env": { @@ -167,6 +172,11 @@ "name": "seo-onboarding-production", "binding": "SEO_ONBOARDING_WORKFLOW", "class_name": "SeoOnboardingWorkflow" + }, + { + "name": "seo-strategy-suggestions-production", + "binding": "SEO_STRATEGY_SUGGESTIONS_WORKFLOW", + "class_name": "SeoStrategySuggestionsWorkflow" } ], "routes": [ @@ -247,6 +257,11 @@ "name": "seo-onboarding", "binding": "SEO_ONBOARDING_WORKFLOW", "class_name": "SeoOnboardingWorkflow" + }, + { + "name": "seo-strategy-suggestions", + "binding": "SEO_STRATEGY_SUGGESTIONS_WORKFLOW", + "class_name": "SeoStrategySuggestionsWorkflow" } ], "routes": [ diff --git a/packages/api-seo/src/context.ts b/packages/api-seo/src/context.ts index 174673b4..14beed8c 100644 --- a/packages/api-seo/src/context.ts +++ b/packages/api-seo/src/context.ts @@ -24,6 +24,7 @@ export const createApiContext = ( | "seoPlannerWorkflow" | "seoWriterWorkflow" | "seoOnboardingWorkflow" + | "seoStrategySuggestionsWorkflow" | "cacheKV" | "scheduler" >, diff --git a/packages/api-seo/src/lib/task.ts b/packages/api-seo/src/lib/task.ts index 5899d9b9..566adc9e 100644 --- a/packages/api-seo/src/lib/task.ts +++ b/packages/api-seo/src/lib/task.ts @@ -38,6 +38,16 @@ export async function createTask({ }); return { provider: "cloudflare" as const, taskId: instance.id }; } + case "seo-generate-strategy-suggestions": { + const instance = + await workflows.seoStrategySuggestionsWorkflow.create({ + id: + workflowInstanceId ?? + `strategy_${crypto.randomUUID()}`, + params: input, + }); + return { provider: "cloudflare" as const, taskId: instance.id }; + } case "seo-plan-keyword": { const instance = await workflows.seoPlannerWorkflow.create({ id: workflowInstanceId ?? `plan_${crypto.randomUUID()}`, diff --git a/packages/api-seo/src/routes/task.ts b/packages/api-seo/src/routes/task.ts index ccb41653..e0ece7bf 100644 --- a/packages/api-seo/src/routes/task.ts +++ b/packages/api-seo/src/routes/task.ts @@ -104,6 +104,9 @@ const status = protectedBase if (taskRun.inputData.type === "seo-understand-site") { return context.seoOnboardingWorkflow.get(taskRun.taskId); } + if (taskRun.inputData.type === "seo-generate-strategy-suggestions") { + return context.seoStrategySuggestionsWorkflow.get(taskRun.taskId); + } return null; })(); diff --git a/packages/api-seo/src/types.ts b/packages/api-seo/src/types.ts index e364e240..9f5d2957 100644 --- a/packages/api-seo/src/types.ts +++ b/packages/api-seo/src/types.ts @@ -20,6 +20,7 @@ import type { import type { router } from "./routes"; import type { SeoOnboardingWorkflowBinding } from "./workflows/onboarding-workflow"; import type { SeoPlannerWorkflowBinding } from "./workflows/planner-workflow"; +import type { SeoStrategySuggestionsWorkflowBinding } from "./workflows/strategy-suggestions-workflow"; import type { SeoWriterWorkflowBinding } from "./workflows/writer-workflow"; export type Router = UnlaziedRouter; @@ -60,6 +61,7 @@ export interface InitialContext extends BaseContextWithAuth { seoPlannerWorkflow: SeoPlannerWorkflowBinding; seoWriterWorkflow: SeoWriterWorkflowBinding; seoOnboardingWorkflow: SeoOnboardingWorkflowBinding; + seoStrategySuggestionsWorkflow: SeoStrategySuggestionsWorkflowBinding; cacheKV: KVNamespace; scheduler: DurableObjectStub; } diff --git a/packages/api-seo/src/workflows/index.ts b/packages/api-seo/src/workflows/index.ts index 27b32bfa..20c06a62 100644 --- a/packages/api-seo/src/workflows/index.ts +++ b/packages/api-seo/src/workflows/index.ts @@ -1,6 +1,7 @@ import { env as cloudflareEnv } from "cloudflare:workers"; import type { SeoOnboardingWorkflowBinding } from "./onboarding-workflow"; import type { SeoPlannerWorkflowBinding } from "./planner-workflow"; +import type { SeoStrategySuggestionsWorkflowBinding } from "./strategy-suggestions-workflow"; import type { SeoWriterWorkflowBinding } from "./writer-workflow"; export const createWorkflows = () => { @@ -8,13 +9,16 @@ export const createWorkflows = () => { SEO_PLANNER_WORKFLOW: SeoPlannerWorkflowBinding; SEO_WRITER_WORKFLOW: SeoWriterWorkflowBinding; SEO_ONBOARDING_WORKFLOW: SeoOnboardingWorkflowBinding; + SEO_STRATEGY_SUGGESTIONS_WORKFLOW: SeoStrategySuggestionsWorkflowBinding; }; return { seoPlannerWorkflow: castEnv.SEO_PLANNER_WORKFLOW, seoWriterWorkflow: castEnv.SEO_WRITER_WORKFLOW, seoOnboardingWorkflow: castEnv.SEO_ONBOARDING_WORKFLOW, + seoStrategySuggestionsWorkflow: castEnv.SEO_STRATEGY_SUGGESTIONS_WORKFLOW, }; }; export { SeoOnboardingWorkflow } from "./onboarding-workflow"; export { SeoPlannerWorkflow } from "./planner-workflow"; +export { SeoStrategySuggestionsWorkflow } from "./strategy-suggestions-workflow"; export { SeoWriterWorkflow } from "./writer-workflow"; diff --git a/packages/api-seo/src/workflows/onboarding-workflow.ts b/packages/api-seo/src/workflows/onboarding-workflow.ts index f7b7b76a..b493edd9 100644 --- a/packages/api-seo/src/workflows/onboarding-workflow.ts +++ b/packages/api-seo/src/workflows/onboarding-workflow.ts @@ -7,8 +7,10 @@ import { NonRetryableError } from "cloudflare:workflows"; import { google } from "@ai-sdk/google"; import { openai } from "@ai-sdk/openai"; import { toSlug } from "@rectangular-labs/core/format/to-slug"; -import type { seoUnderstandSiteTaskInputSchema } from "@rectangular-labs/core/schemas/task-parsers"; -import { seoUnderstandSiteTaskOutputSchema } from "@rectangular-labs/core/schemas/task-parsers"; +import { + type seoUnderstandSiteTaskInputSchema, + seoUnderstandSiteTaskOutputSchema, +} from "@rectangular-labs/core/schemas/task-parsers"; import { createDb } from "@rectangular-labs/db"; import { updateSeoProject } from "@rectangular-labs/db/operations"; import { @@ -20,6 +22,7 @@ import { } from "ai"; import { type } from "arktype"; import { createWebToolsWithMetadata } from "../lib/ai/tools/web-tools"; +import { createTask } from "../lib/task"; import { DEFAULT_BRAND_VOICE } from "../lib/workspace/workflow.constant"; import type { InitialContext } from "../types"; @@ -58,34 +61,35 @@ export class SeoOnboardingWorkflow extends WorkflowEntrypoint< return projectResult; }); - const { name } = await step.do( - "extract project name from homepage title", - { - timeout: "10 minutes", - }, - async () => { - let homepageTitle = ""; - try { - const response = await fetch(project.websiteUrl, { - headers: { - "User-Agent": - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", - }, - }); - if (response.ok) { - const html = await response.text(); - const match = html.match(/]*>([^<]+)<\/title>/i); - homepageTitle = match?.[1]?.trim() ?? ""; + const [{ name }, businessBackground] = await Promise.all([ + step.do( + "extract project name from homepage title", + { + timeout: "5 minutes", + }, + async () => { + let homepageTitle = ""; + try { + const response = await fetch(project.websiteUrl, { + headers: { + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + }, + }); + if (response.ok) { + const html = await response.text(); + const match = html.match(/]*>([^<]+)<\/title>/i); + homepageTitle = match?.[1]?.trim() ?? ""; + } + } catch (error) { + logError("failed to fetch homepage", { + url: project.websiteUrl, + error: error instanceof Error ? error.message : String(error), + }); } - } catch (error) { - logError("failed to fetch homepage", { - url: project.websiteUrl, - error: error instanceof Error ? error.message : String(error), - }); - } - logInfo("homepage title", { homepageTitle }); - const system = `You extract a company or product name from a homepage title. + logInfo("homepage title", { homepageTitle }); + const system = `You extract a company or product name from a homepage title. ## Task - Analyze the provided homepage title and URL to derive the entity name. @@ -97,87 +101,159 @@ export class SeoOnboardingWorkflow extends WorkflowEntrypoint< ## Expectations - Provide concise, exact values without extra commentary.`; - const { experimental_output } = await generateText({ - model: google("gemini-3-flash-preview"), - system, - prompt: `Context: + const { experimental_output } = await generateText({ + model: google("gemini-3-flash-preview"), + system, + prompt: `Context: URL: ${project.websiteUrl} Homepage Title: ${homepageTitle} Extract the name from the above context.`, - experimental_output: Output.object({ - schema: jsonSchema<{ name: string }>( - type({ - name: "string", - }).toJsonSchema() as JSONSchema7, - ), - }), + experimental_output: Output.object({ + schema: jsonSchema<{ name: string }>( + type({ + name: "string", + }).toJsonSchema() as JSONSchema7, + ), + }), + }); + + return experimental_output; + }, + ), + step.do( + "research business background", + { + timeout: "30 minutes", + }, + async () => { + const { tools } = createWebToolsWithMetadata(project, this.env.CACHE); + const system = `You are an SEO research expert extracting concise, high-signal business context. + +## Task +- Research the provided website to extract business context, target audience, competitors and content strategy insights. + +## Guidelines +- Use ONLY the web_search and web_fetch tools to gather information. We want to make sure that the date is accurate and up to date at all times. +- Prioritize the site's own pages over external sources. +- Be conservative and never guess. Only report verified information that is publicly stated. +- If uncertain about location or language, use schema defaults (US, San Francisco, en). + +## Expectations +- All information must be current and up to date. +- Provide clear and concise, high-signal insights rather than verbose descriptions.`; + + logInfo("researching business background", { + projectId: project.id, + websiteUrl: project.websiteUrl, + }); + const outputResult = await generateText({ + model: openai("gpt-5.2"), + system, + tools, + prompt: `Extract business background from: ${project.websiteUrl}`, + stopWhen: [stepCountIs(35)], + onStepFinish: (step) => { + logInfo("[backgroundResearch] step finished", { + toolCalls: step.toolCalls, + toolResults: step.toolResults, + }); + }, + experimental_output: Output.object({ + schema: jsonSchema< + type.infer< + typeof seoUnderstandSiteTaskOutputSchema + >["businessBackground"] + >( + seoUnderstandSiteTaskOutputSchema + .get("businessBackground") + .toJsonSchema() as JSONSchema7, + ), + }), + }); + + return outputResult.experimental_output; + }, + ), + ]); + + await step.do( + "update project name/slug and business background", + async () => { + const extractedName = name.trim(); + const nextName = extractedName || project.name; + const nextSlug = extractedName ? toSlug(extractedName) : project.slug; + logInfo("updating project name and saving business background", { + projectId: project.id, + name: nextName, + slug: nextSlug, + }); + + const db = createDb(); + const updateResult = await updateSeoProject(db, { + id: project.id, + organizationId: project.organizationId, + name: nextName, + slug: nextSlug, + businessBackground: { + ...businessBackground, + version: "v1", + }, }); - return experimental_output; + if (!updateResult.ok) { + logError("failed to update project name and business background", { + projectId: project.id, + error: updateResult.error, + }); + throw updateResult.error; + } }, ); - await step.do("update project name/slug from homepage title", async () => { - const extractedName = name.trim(); - const nextName = extractedName || project.name; - const nextSlug = extractedName ? toSlug(extractedName) : project.slug; - - if (!extractedName) { - return { name: nextName, slug: nextSlug, updated: false }; - } - - logInfo("updating project name", { - projectId: project.id, - name: nextName, - slug: nextSlug, - }); + await step.do("trigger strategy suggestions workflow", async () => { const db = createDb(); - const updateResult = await updateSeoProject(db, { - id: project.id, - organizationId: project.organizationId, - name: nextName, - slug: nextSlug ?? project.slug, + const taskResult = await createTask({ + db, + userId: undefined, + input: { + type: "seo-generate-strategy-suggestions", + projectId: project.id, + }, + workflowInstanceId: `strategy_${event.instanceId}_${crypto.randomUUID().slice(0, 5)}`, }); - - if (!updateResult.ok) { - logError("failed to update project name", { + if (!taskResult.ok) { + logError("failed to trigger strategy suggestions workflow", { projectId: project.id, - error: updateResult.error, + error: taskResult.error, }); - throw updateResult.error; } - - return { name: nextName, slug: nextSlug, updated: true }; }); - const researchResult = await step.do( - "research business background", - { - timeout: "30 minutes", - }, + const brandVoiceResult = await step.do( + "generate brand voice", + { timeout: "10 minutes" }, async () => { const { tools } = createWebToolsWithMetadata(project, this.env.CACHE); - const system = `You are an SEO research expert extracting concise, high-signal business context. + const system = `You are an SEO research expert extracting a brand's writing tone. ## Task -- Research the provided website to extract business context, target audience, competitors and content strategy insights. - Find 3-5 blog/article URLs from the SAME DOMAIN as the website to analyze writing tone. +- Summarize the writing tone based on actual blog samples found to provide a concrete voice for new articles. ## Guidelines -- Use ONLY the web_search and web_fetch tools to gather information. We want to make sure that the date is accurate and up to date at all times. +- Use ONLY the web_search and web_fetch tools to gather information. - Prioritize the site's own pages over external sources. -- Be conservative and never guess. Only report verified information that is publicly stated -- If uncertain about location or language, use schema defaults (US, San Francisco, en). -- For blog tone research, look for blogs and articles that are published on the same domain (can be a sub-domain) as the website. If none exist, simply return an empty string for the brand voice. +- Be conservative and never guess. Only report verified information that is publicly stated. +- You are encouraged to include writing examples to showcase what you mean when you describe various aspects of the brand voice. + - For example if you say that the brand is authoritative and neutral, add {QUOTE_FROM_BLOG} +- If no blog samples exist, return an empty string for brandVoice. ## Expectations -- All information must be current and up to date. -- Provide clear and concise, high-signal insights rather than verbose descriptions. -- Summarize the writing tone based on actual blog samples found.`; +- Provide clear and concise tone attributes.`; - logInfo("researching business background", { + logInfo("researching brand voice", { projectId: project.id, websiteUrl: project.websiteUrl, }); @@ -185,45 +261,46 @@ Extract the name from the above context.`, model: openai("gpt-5.2"), system, tools, - prompt: `Extract business background and blog tone from: ${project.websiteUrl}`, - stopWhen: [stepCountIs(35)], + prompt: `Extract brand voice from: ${project.websiteUrl}`, + stopWhen: [stepCountIs(25)], onStepFinish: (step) => { - logInfo("step finished", { + logInfo("step to extract brand voice finished", { toolCalls: step.toolCalls, toolResults: step.toolResults, }); }, experimental_output: Output.object({ - schema: jsonSchema< - type.infer - >(seoUnderstandSiteTaskOutputSchema.toJsonSchema() as JSONSchema7), + schema: jsonSchema<{ brandVoice: string }>( + type({ + brandVoice: "string", + }).toJsonSchema() as JSONSchema7, + ), }), }); - const researchResult = outputResult.experimental_output; - - const brandVoiceFromSamples = researchResult.brandVoice.trim() || ""; - const nextBrandVoice = `## Writing Tone + return outputResult.experimental_output; + }, + ); + const { brandVoice } = await step.do( + "update project brand voice", + async () => { + const brandVoiceFromSamples = brandVoiceResult.brandVoice.trim() || ""; + const nextBrandVoice = `${ + brandVoiceFromSamples + ? `## Writing Tone + ${brandVoiceFromSamples} - +` + : "" + } ## Writing Guidelines ${DEFAULT_BRAND_VOICE}`; - - logInfo("updating project with research results", { - projectId: project.id, - businessBackground: researchResult.businessBackground, - brandVoice: nextBrandVoice, - }); const db = createDb(); const updateResult = await updateSeoProject(db, { id: project.id, organizationId: project.organizationId, - businessBackground: { - ...researchResult.businessBackground, - version: "v1", - }, writingSettings: { ...project.writingSettings, brandVoice: nextBrandVoice, @@ -231,19 +308,22 @@ ${DEFAULT_BRAND_VOICE}`; }); if (!updateResult.ok) { - logError("failed to update project", { + logError("failed to update project brand voice", { projectId: project.id, error: updateResult.error, }); throw updateResult.error; } - return researchResult; + + return { brandVoice: nextBrandVoice }; }, ); return { - ...researchResult, + type: "seo-understand-site", name, + businessBackground, + brandVoice, } satisfies typeof seoUnderstandSiteTaskOutputSchema.infer; } } diff --git a/packages/api-seo/src/workflows/strategy-suggestions-workflow.ts b/packages/api-seo/src/workflows/strategy-suggestions-workflow.ts new file mode 100644 index 00000000..2b83410a --- /dev/null +++ b/packages/api-seo/src/workflows/strategy-suggestions-workflow.ts @@ -0,0 +1,226 @@ +import { + WorkflowEntrypoint, + type WorkflowEvent, + type WorkflowStep, +} from "cloudflare:workers"; +import { NonRetryableError } from "cloudflare:workflows"; +import { openai } from "@ai-sdk/openai"; +import { initAuthHandler } from "@rectangular-labs/auth"; +import { strategySuggestionSchema } from "@rectangular-labs/core/schemas/strategy-parsers"; +import type { + seoStrategySuggestionsTaskInputSchema, + seoStrategySuggestionsTaskOutputSchema, +} from "@rectangular-labs/core/schemas/task-parsers"; +import { createDb } from "@rectangular-labs/db"; +import { + createStrategies, + getSeoProjectById, +} from "@rectangular-labs/db/operations"; +import { + generateText, + type JSONSchema7, + jsonSchema, + Output, + stepCountIs, +} from "ai"; +import { type } from "arktype"; +import { apiEnv } from "../env"; +import { formatBusinessBackground } from "../lib/ai/format-business-background"; +import { createDataforseoToolWithMetadata } from "../lib/ai/tools/dataforseo-tool"; +import { createGscToolWithMetadata } from "../lib/ai/tools/google-search-console-tool"; +import { createStrategyToolsWithMetadata } from "../lib/ai/tools/strategy-tools"; +import { createWebToolsWithMetadata } from "../lib/ai/tools/web-tools"; +import { getGscIntegrationForProject } from "../lib/database/gsc-integration"; +import type { InitialContext } from "../types"; + +function logInfo(message: string, data?: Record) { + console.info(`[SeoStrategySuggestionsWorkflow] ${message}`, data ?? {}); +} + +type StrategySuggestionsInput = + typeof seoStrategySuggestionsTaskInputSchema.infer; +export type SeoStrategySuggestionsWorkflowBinding = + Workflow; +export class SeoStrategySuggestionsWorkflow extends WorkflowEntrypoint< + { + CACHE: InitialContext["cacheKV"]; + }, + StrategySuggestionsInput +> { + async run( + event: WorkflowEvent, + step: WorkflowStep, + ) { + const input = event.payload; + + logInfo("start", { + instanceId: event.instanceId, + projectId: input.projectId, + }); + + const project = await step.do("load project", async () => { + const db = createDb(); + const projectResult = await getSeoProjectById(db, input.projectId); + if (!projectResult.ok) { + console.error( + `[SeoStrategySuggestionsWorkflow] ${projectResult.error}`, + ); + throw projectResult.error; + } + if (!projectResult.value) { + throw new NonRetryableError( + `Missing project for Project ID: ${input.projectId}`, + ); + } + return projectResult.value; + }); + + const suggestionResult = await step.do( + "generate strategy suggestions", + { + timeout: "10 minutes", + }, + async () => { + logInfo("creating strategy suggestion tools", { + instanceId: event.instanceId, + projectId: input.projectId, + }); + const { tools: webTools } = createWebToolsWithMetadata( + project, + this.env.CACHE, + ); + const dataforseoTools = createDataforseoToolWithMetadata(project); + const db = createDb(); + const strategyTools = createStrategyToolsWithMetadata({ + db, + projectId: project.id, + }); + const env = apiEnv(); + const auth = initAuthHandler({ + baseURL: env.SEO_URL, + db, + encryptionKey: env.AUTH_SEO_ENCRYPTION_KEY, + fromEmail: env.AUTH_SEO_FROM_EMAIL, + inboundApiKey: env.SEO_INBOUND_API_KEY, + credentialVerificationType: env.AUTH_SEO_CREDENTIAL_VERIFICATION_TYPE, + discordClientId: env.AUTH_SEO_DISCORD_ID, + discordClientSecret: env.AUTH_SEO_DISCORD_SECRET, + githubClientId: env.AUTH_SEO_GITHUB_ID, + githubClientSecret: env.AUTH_SEO_GITHUB_SECRET, + googleClientId: env.AUTH_SEO_GOOGLE_CLIENT_ID, + googleClientSecret: env.AUTH_SEO_GOOGLE_CLIENT_SECRET, + }); + const gscIntegrationResult = await getGscIntegrationForProject({ + db, + projectId: project.id, + organizationId: project.organizationId, + authOverride: auth, + }); + if (!gscIntegrationResult.ok) { + throw new Error( + `Something went wrong getting gsc integration ${gscIntegrationResult.error}`, + { + cause: gscIntegrationResult.error, + }, + ); + } + + const gscTools = createGscToolWithMetadata({ + accessToken: gscIntegrationResult.value?.accessToken ?? null, + siteUrl: gscIntegrationResult.value?.config?.domain ?? null, + siteType: gscIntegrationResult.value?.config?.propertyType ?? null, + }); + + const system = `You are an SEO strategist generating onboarding strategy suggestions. + +## Task +- Generate exactly 2 strategy suggestions for the project. +- Each suggestion must include: name, motivation, description, goal. + +## Data Usage +- Use available tools (Google Search Console, DataForSEO, web search) directly to ground recommendations. +- Always check existing strategies via list_strategies and avoid duplicating active strategies. +- If you need more context, use get_strategy_details for relevant strategies. +- If Google Search Console is not available, rely on competitor and keyword tools plus public site info. + +## Suggestion Mix Rules +- If GSC data is available, include at least one "improve existing content" strategy. +- Always include one "create new content cluster" strategy. + +## Output Requirements +- Keep each strategy concise and actionable. +- Use realistic targets for goals and success criteria. +- Output MUST match the provided JSON schema.`; + logInfo("Starting strategy suggestion generation", { + instanceId: event.instanceId, + projectId: input.projectId, + }); + const outputResult = await generateText({ + model: openai("gpt-5.2"), + system, + tools: { + ...webTools, + ...dataforseoTools.tools, + ...strategyTools.tools, + ...(gscIntegrationResult.value ? gscTools.tools : {}), + }, + prompt: `Project website: ${project.websiteUrl} +Business background:${formatBusinessBackground(project.businessBackground)} + +Generate strategy suggestions now.`, + stopWhen: [stepCountIs(40)], + onStepFinish: (step) => { + logInfo("step to generate suggestion finished", { + toolCalls: step.toolCalls, + toolResults: step.toolResults, + }); + }, + experimental_output: Output.object({ + schema: jsonSchema<{ + suggestions: (typeof strategySuggestionSchema.infer)[]; + }>( + type({ + suggestions: strategySuggestionSchema.array(), + }).toJsonSchema() as JSONSchema7, + ), + }), + }); + + return outputResult.experimental_output; + }, + ); + + const createdStrategyIds = await step.do( + "save strategy suggestions", + async () => { + const db = createDb(); + const strategyResult = await createStrategies( + db, + suggestionResult.suggestions.map((suggestion) => { + return { + ...suggestion, + projectId: project.id, + status: "suggestion", + }; + }), + ); + if (!strategyResult.ok) { + throw strategyResult.error; + } + return strategyResult.value.map((strategy) => strategy.id); + }, + ); + + logInfo("complete", { + instanceId: event.instanceId, + projectId: project.id, + createdStrategyCount: createdStrategyIds.length, + }); + + return { + type: "seo-generate-strategy-suggestions", + projectId: project.id, + strategyIds: createdStrategyIds, + } satisfies typeof seoStrategySuggestionsTaskOutputSchema.infer; + } +} diff --git a/packages/core/src/schemas/task-parsers.ts b/packages/core/src/schemas/task-parsers.ts index 8e3e2147..e1d20741 100644 --- a/packages/core/src/schemas/task-parsers.ts +++ b/packages/core/src/schemas/task-parsers.ts @@ -4,18 +4,23 @@ import { businessBackgroundSchema } from "./project-parsers"; export const understandSiteTaskInputSchema = type({ type: "'understand-site'", - projectId: "string", + projectId: "string.uuid", websiteUrl: "string", }); export const seoUnderstandSiteTaskInputSchema = type({ type: "'seo-understand-site'", - projectId: "string", + projectId: "string.uuid", +}); + +export const seoStrategySuggestionsTaskInputSchema = type({ + type: "'seo-generate-strategy-suggestions'", + projectId: "string.uuid", }); export const seoPlanKeywordTaskInputSchema = type({ type: "'seo-plan-keyword'", - projectId: "string", + projectId: "string.uuid", organizationId: "string", chatId: "string|null", draftId: "string.uuid", @@ -37,6 +42,7 @@ export const taskInputSchema = type.or( seoPlanKeywordTaskInputSchema, seoWriteArticleTaskInputSchema, seoUnderstandSiteTaskInputSchema, + seoStrategySuggestionsTaskInputSchema, ); export const understandSiteTaskOutputSchema = type({ @@ -66,9 +72,16 @@ export const seoUnderstandSiteTaskOutputSchema = type({ brandVoice: type("string"), }); +export const seoStrategySuggestionsTaskOutputSchema = type({ + type: "'seo-generate-strategy-suggestions'", + projectId: "string", + strategyIds: type("string.uuid").array(), +}); + export const taskOutputSchema = type.or( understandSiteTaskOutputSchema, seoPlanKeywordTaskOutputSchema, seoWriteArticleTaskOutputSchema, seoUnderstandSiteTaskOutputSchema, + seoStrategySuggestionsTaskOutputSchema, ); diff --git a/packages/db/src/operations/seo/project-operations.ts b/packages/db/src/operations/seo/project-operations.ts index 0e5c00de..3dd7cec9 100644 --- a/packages/db/src/operations/seo/project-operations.ts +++ b/packages/db/src/operations/seo/project-operations.ts @@ -64,7 +64,8 @@ export async function deleteSeoProject( export function getSeoProjectById(db: DB, id: string) { return safe(() => db.query.seoProject.findFirst({ - where: (table, { eq }) => eq(table.id, id), + where: (table, { eq, and, isNull }) => + and(eq(table.id, id), isNull(table.deletedAt)), }), ); } @@ -136,8 +137,12 @@ export async function getSeoProjectByIdentifierAndOrgId< projectResearchWorkflowId: true, ...(includeSettings ?? {}), }, - where: (table, { eq, and }) => - and(check(table), eq(table.organizationId, orgId)), + where: (table, { eq, and, isNull }) => + and( + check(table), + eq(table.organizationId, orgId), + isNull(table.deletedAt), + ), }), ); if (!result.ok) { @@ -161,12 +166,13 @@ export async function getSeoProjectWithWritingSettingAndAuthors( id: true, writingSettings: true, }, - where: (table, { eq }) => + where: (table, { eq, isNull }) => and( isSlug ? eq(table.slug, projectIdentifier) : eq(table.id, projectIdentifier), eq(table.organizationId, organizationId), + isNull(table.deletedAt), ), with: { authors: true, diff --git a/packages/db/src/operations/seo/strategy-operations.ts b/packages/db/src/operations/seo/strategy-operations.ts index b1462b94..28a825a3 100644 --- a/packages/db/src/operations/seo/strategy-operations.ts +++ b/packages/db/src/operations/seo/strategy-operations.ts @@ -65,21 +65,23 @@ export async function getStrategyDetails(args: { ); } -export async function createStrategy( +export async function createStrategies( db: DB | DBTransaction, - values: typeof seoStrategyInsertSchema.infer, + values: (typeof seoStrategyInsertSchema.infer)[], ) { + if (values.length === 0) { + return ok([]); + } const result = await safe(() => db.insert(schema.seoStrategy).values(values).returning(), ); if (!result.ok) { return result; } - const strategy = result.value[0]; - if (!strategy) { + if (result.value.length !== values.length) { return err(new Error("Failed to create strategy")); } - return ok(strategy); + return ok(result.value); } export async function updateStrategy( From 5a4acde22d610263a8ad6be9d3f9738a1b3cf93a Mon Sep 17 00:00:00 2001 From: ElasticBottle Date: Sun, 1 Feb 2026 11:25:50 +0800 Subject: [PATCH 08/60] chore(core): remove cadence from publishing schedule --- packages/api-seo/src/lib/workspace/constants.ts | 5 ----- packages/core/src/schemas/project-parsers.ts | 11 ----------- 2 files changed, 16 deletions(-) diff --git a/packages/api-seo/src/lib/workspace/constants.ts b/packages/api-seo/src/lib/workspace/constants.ts index 47c3673b..ba61ba11 100644 --- a/packages/api-seo/src/lib/workspace/constants.ts +++ b/packages/api-seo/src/lib/workspace/constants.ts @@ -20,11 +20,6 @@ export const DEFAULT_WRITING_SETTINGS: typeof writingSettingsSchema.infer = { export const DEFAULT_PUBLISHING_SETTINGS: typeof publishingSettingsSchema.infer = { version: "v1", - cadence: { - period: "daily", - frequency: 1, - allowedDays: ["mon", "tue", "wed", "thu", "fri"], - }, requireContentReview: true, requireSuggestionReview: true, }; diff --git a/packages/core/src/schemas/project-parsers.ts b/packages/core/src/schemas/project-parsers.ts index 8e1e98d2..f88a2b47 100644 --- a/packages/core/src/schemas/project-parsers.ts +++ b/packages/core/src/schemas/project-parsers.ts @@ -126,17 +126,6 @@ export const writingSettingsSchema = type({ export const publishingSettingsSchema = type({ version: "'v1'", - cadence: type({ - // "daily" => frequency is articles/day - // "weekly" => frequency is articles/week - // "monthly" => frequency is articles/month - period: "'daily' | 'weekly' | 'monthly'", - frequency: "number.integer >= 1", - // Days that are eligible for publishing. Deselecting a day means we won't publish on that day. - allowedDays: type( - "'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat' | 'sun'", - ).array(), - }), requireContentReview: "boolean", requireSuggestionReview: "boolean", }); From ff1b39cddab47e3785f4e7d06a11b685742e042e Mon Sep 17 00:00:00 2001 From: ElasticBottle Date: Sun, 1 Feb 2026 11:30:47 +0800 Subject: [PATCH 09/60] feat(api-seo): add strategy api endpoints --- packages/api-seo/src/routes/strategy.ts | 138 ++---------------- .../src/operations/seo/strategy-operations.ts | 2 +- 2 files changed, 14 insertions(+), 126 deletions(-) diff --git a/packages/api-seo/src/routes/strategy.ts b/packages/api-seo/src/routes/strategy.ts index 1ee63723..5ed859dc 100644 --- a/packages/api-seo/src/routes/strategy.ts +++ b/packages/api-seo/src/routes/strategy.ts @@ -1,34 +1,16 @@ import { ORPCError } from "@orpc/server"; import { schema } from "@rectangular-labs/db"; import { - createContentDraft, - createStrategy, createStrategyPhase, - createStrategyPhaseContent, getStrategyDetails, listStrategiesByProjectId, + updateStrategy, updateStrategyPhase, } from "@rectangular-labs/db/operations"; import { type } from "arktype"; import { base, withOrganizationIdBase } from "../context"; import { validateOrganizationMiddleware } from "../lib/middleware/validate-organization"; -const contentDraftInputSchema = schema.seoContentDraftInsertSchema.omit( - "organizationId", - "projectId", - "strategyId", -); - -const phaseContentInputSchema = type({ - action: "'create'|'improve'|'expand'", - "contentDraftId?": "string.uuid|undefined", - "contentDraft?": contentDraftInputSchema.or(type.undefined), - "plannedTitle?": "string|undefined", - "plannedPrimaryKeyword?": "string|undefined", - "role?": "'pillar'|'supporting'|undefined", - "notes?": "string|undefined", -}); - const list = withOrganizationIdBase .route({ method: "GET", path: "/" }) .input( @@ -40,7 +22,7 @@ const list = withOrganizationIdBase .use(validateOrganizationMiddleware, (input) => input.organizationIdentifier) .output( type({ - data: type({ + strategies: type({ "...": schema.seoStrategySelectSchema, phases: schema.seoStrategyPhaseSelectSchema.array(), }).array(), @@ -58,21 +40,7 @@ const list = withOrganizationIdBase }); } - const data = strategiesResult.value.map((strategy) => { - const phases = strategy.phases ?? []; - const currentPhase = - phases.find((phase) => phase.status === "in_progress") ?? - phases.find((phase) => phase.status === "planned") ?? - phases.find((phase) => phase.status === "observing") ?? - phases.at(0) ?? - null; - return { - ...strategy, - currentPhase, - }; - }); - - return { data }; + return { strategies: strategiesResult.value }; }); const get = withOrganizationIdBase @@ -119,105 +87,25 @@ const get = withOrganizationIdBase return strategyResult.value; }); -const create = withOrganizationIdBase - .route({ method: "POST", path: "/" }) +const update = withOrganizationIdBase + .route({ method: "PATCH", path: "/{id}" }) .input( type({ + "...": schema.seoStrategyUpdateSchema, organizationIdentifier: "string", - projectId: "string.uuid", - strategy: schema.seoStrategyInsertSchema.omit("projectId"), - phase: schema.seoStrategyPhaseInsertSchema.omit("strategyId"), - "contents?": phaseContentInputSchema.array(), }), ) .use(validateOrganizationMiddleware, (input) => input.organizationIdentifier) - .output( - type({ - message: "string", - }), - ) + .output(schema.seoStrategySelectSchema) .handler(async ({ context, input }) => { - const contents = input.contents ?? []; - const strategyId = await context.db.transaction(async (tx) => { - const strategyResult = await createStrategy(tx, { - ...input.strategy, - projectId: input.projectId, - }); - if (!strategyResult.ok) { - throw new ORPCError("INTERNAL_SERVER_ERROR", { - message: "Failed to create strategy.", - cause: strategyResult.error, - }); - } - - const phaseResult = await createStrategyPhase(tx, { - ...input.phase, - strategyId: strategyResult.value.id, - }); - if (!phaseResult.ok) { - throw new ORPCError("INTERNAL_SERVER_ERROR", { - message: "Failed to create strategy phase.", - cause: phaseResult.error, - }); - } - - for (const content of contents) { - let contentDraftId = content.contentDraftId; - if (!contentDraftId && content.contentDraft) { - const draftResult = await createContentDraft(tx, { - ...content.contentDraft, - organizationId: context.organization.id, - projectId: input.projectId, - strategyId: strategyResult.value.id, - }); - if (!draftResult.ok) { - throw new ORPCError("INTERNAL_SERVER_ERROR", { - message: "Failed to create content draft.", - cause: draftResult.error, - }); - } - contentDraftId = draftResult.value.id; - } - - const phaseContentResult = await createStrategyPhaseContent(tx, { - phaseId: phaseResult.value.id, - contentDraftId, - action: content.action, - plannedTitle: content.plannedTitle ?? content.contentDraft?.title, - plannedPrimaryKeyword: - content.plannedPrimaryKeyword ?? - content.contentDraft?.primaryKeyword, - role: content.role, - notes: content.notes, - }); - if (!phaseContentResult.ok) { - throw new ORPCError("INTERNAL_SERVER_ERROR", { - message: "Failed to create strategy phase content.", - cause: phaseContentResult.error, - }); - } - } - - return strategyResult.value.id; - }); - - const strategyResult = await getStrategyDetails({ - db: context.db, - projectId: input.projectId, - strategyId, - }); - if (!strategyResult.ok) { + const updateResult = await updateStrategy(context.db, input); + if (!updateResult.ok) { throw new ORPCError("INTERNAL_SERVER_ERROR", { - message: "Failed to load strategy.", - cause: strategyResult.error, - }); - } - if (!strategyResult.value) { - throw new ORPCError("NOT_FOUND", { - message: "No strategy found after creation.", + message: "Failed to update strategy.", + cause: updateResult.error, }); } - return strategyResult.value; + return updateResult.value; }); const createPhase = withOrganizationIdBase @@ -274,7 +162,7 @@ export default base .router({ list, get, - create, + update, phases: { create: createPhase, update: updatePhase, diff --git a/packages/db/src/operations/seo/strategy-operations.ts b/packages/db/src/operations/seo/strategy-operations.ts index 28a825a3..7ef14ba5 100644 --- a/packages/db/src/operations/seo/strategy-operations.ts +++ b/packages/db/src/operations/seo/strategy-operations.ts @@ -36,8 +36,8 @@ export async function getStrategyDetails(args: { args.db.query.seoStrategy.findFirst({ where: (table, { eq, isNull, and }) => and( - eq(table.projectId, args.projectId), eq(table.id, args.strategyId), + eq(table.projectId, args.projectId), isNull(table.deletedAt), ), with: { From fa20ed7da72fff7d7d97f6e800ddd251d829133b Mon Sep 17 00:00:00 2001 From: ElasticBottle Date: Sun, 1 Feb 2026 11:36:17 +0800 Subject: [PATCH 10/60] chore(api-seo): add tools to view strategies in agent --- .../api-seo/src/lib/ai/strategist-agent.ts | 6 + .../src/lib/ai/tools/strategy-tools.ts | 150 ++++++++++++++++++ .../src/lib/database/gsc-integration.ts | 10 +- 3 files changed, 163 insertions(+), 3 deletions(-) create mode 100644 packages/api-seo/src/lib/ai/tools/strategy-tools.ts diff --git a/packages/api-seo/src/lib/ai/strategist-agent.ts b/packages/api-seo/src/lib/ai/strategist-agent.ts index 1cd09d85..e8f561f9 100644 --- a/packages/api-seo/src/lib/ai/strategist-agent.ts +++ b/packages/api-seo/src/lib/ai/strategist-agent.ts @@ -10,6 +10,7 @@ import { createGscToolWithMetadata } from "./tools/google-search-console-tool"; import { createPlannerToolsWithMetadata } from "./tools/planner-tools"; import { createSettingsToolsWithMetadata } from "./tools/settings-tools"; import { createSkillTools } from "./tools/skill-tools"; +import { createStrategyToolsWithMetadata } from "./tools/strategy-tools"; import { createTodoToolWithMetadata, formatTodoFocusReminder, @@ -132,6 +133,10 @@ export function createStrategistAgent({ project, context, }); + const strategyTools = createStrategyToolsWithMetadata({ + db: context.db, + projectId: project.id, + }); const skillDefinitions: AgentToolDefinition[] = [ ...settingsTools.toolDefinitions, @@ -139,6 +144,7 @@ export function createStrategistAgent({ ...gscTools.toolDefinitions, ...dataforseoTools.toolDefinitions, ...createArticleTool.toolDefinitions, + ...strategyTools.toolDefinitions, ...readOnlyFileToolDefinitions, ]; const skillsSection = formatToolSkillsSection(skillDefinitions); diff --git a/packages/api-seo/src/lib/ai/tools/strategy-tools.ts b/packages/api-seo/src/lib/ai/tools/strategy-tools.ts new file mode 100644 index 00000000..d7e0ba5e --- /dev/null +++ b/packages/api-seo/src/lib/ai/tools/strategy-tools.ts @@ -0,0 +1,150 @@ +import type { DB } from "@rectangular-labs/db"; +import { + getStrategyDetails, + listStrategiesByProjectId, +} from "@rectangular-labs/db/operations"; +import { type JSONSchema7, jsonSchema, tool } from "ai"; +import { type } from "arktype"; +import type { AgentToolDefinition } from "./utils"; + +const listStrategiesInputSchema = type({ + "includeSuggestions?": "boolean", + "includeArchived?": "boolean", + "limit?": "number", +}); + +const strategyDetailsInputSchema = type({ + strategyId: "string.uuid", + "snapshotLimit?": "number", +}); + +export function createStrategyToolsWithMetadata(args: { + db: DB; + projectId: string; +}) { + const listStrategies = tool({ + description: + "List existing strategies for the project in a compact, token-efficient format.", + inputSchema: jsonSchema( + listStrategiesInputSchema.toJsonSchema() as JSONSchema7, + ), + async execute({ + includeSuggestions = false, + includeArchived = false, + limit, + }) { + const result = await listStrategiesByProjectId({ + db: args.db, + projectId: args.projectId, + }); + if (!result.ok) { + return { success: false, message: result.error.message }; + } + + const filtered = result.value.filter((strategy) => { + if (!includeSuggestions && strategy.status === "suggestion") { + return false; + } + if (!includeArchived && strategy.status === "dismissed") { + return false; + } + return true; + }); + + const limited = + typeof limit === "number" + ? filtered.slice(0, Math.max(0, limit)) + : filtered; + const strategies = limited.map((strategy) => ({ + id: strategy.id, + name: strategy.name, + status: strategy.status, + goal: strategy.goal, + updatedAt: + strategy.updatedAt?.toISOString?.() ?? + String(strategy.updatedAt ?? ""), + phaseCount: strategy.phases?.length ?? 0, + })); + + return { + success: true, + count: strategies.length, + strategies, + }; + }, + }); + + const getStrategyDetailsTool = tool({ + description: + "Fetch detailed strategy information, including phases, phase contents, and recent snapshots.", + inputSchema: jsonSchema( + strategyDetailsInputSchema.toJsonSchema() as JSONSchema7, + ), + async execute({ strategyId, snapshotLimit = 5 }) { + const detailResult = await getStrategyDetails({ + db: args.db, + projectId: args.projectId, + strategyId, + }); + if (!detailResult.ok) { + return { success: false, message: detailResult.error.message }; + } + if (!detailResult.value) { + return { success: false, message: "Strategy not found" }; + } + + const snapshots = await args.db.query.seoStrategySnapshot.findMany({ + where: (table, { eq, isNull, and }) => + and(eq(table.strategyId, strategyId), isNull(table.deletedAt)), + orderBy: (fields, { desc }) => [desc(fields.takenAt)], + limit: Math.max(1, Math.min(20, snapshotLimit)), + with: { + contents: { + where: (table, { isNull }) => isNull(table.deletedAt), + with: { + contentDraft: { + columns: { + contentMarkdown: false, + outline: false, + notes: false, + }, + }, + }, + }, + }, + }); + + return { + success: true, + strategy: detailResult.value, + snapshots, + }; + }, + }); + + const tools = { + list_strategies: listStrategies, + get_strategy_details: getStrategyDetailsTool, + } as const; + + const toolDefinitions: AgentToolDefinition[] = [ + { + toolName: "list_strategies", + toolDescription: + "List existing project strategies in a compact format (id, name, status, goal, phase count).", + toolInstruction: + "Use first to avoid duplicating active strategies. Default excludes suggestions/archived unless needed. Limit results for token efficiency.", + tool: listStrategies, + }, + { + toolName: "get_strategy_details", + toolDescription: + "Fetch full strategy details including phases, content items, and recent snapshots.", + toolInstruction: + "Use when you need full context for a specific strategy. Provide strategyId and optional snapshotLimit.", + tool: getStrategyDetailsTool, + }, + ]; + + return { toolDefinitions, tools }; +} diff --git a/packages/api-seo/src/lib/database/gsc-integration.ts b/packages/api-seo/src/lib/database/gsc-integration.ts index 44645c39..56730e40 100644 --- a/packages/api-seo/src/lib/database/gsc-integration.ts +++ b/packages/api-seo/src/lib/database/gsc-integration.ts @@ -1,12 +1,14 @@ -import { getContext } from "@rectangular-labs/api-core/lib/context-storage"; +import type { Auth } from "@rectangular-labs/auth"; import type { DB } from "@rectangular-labs/db"; import { getProviderIntegration } from "@rectangular-labs/db/operations"; import { ok, safe } from "@rectangular-labs/result"; +import { getContext } from "../../context"; export async function getGscIntegrationForProject(params: { db: DB; projectId: string; organizationId: string; + authOverride?: Auth; }) { const integrationResult = await getProviderIntegration(params.db, { projectId: params.projectId, @@ -23,11 +25,13 @@ export async function getGscIntegrationForProject(params: { return ok(null); } - const context = getContext(); + const context = params.authOverride + ? { auth: params.authOverride } + : getContext(); const accessTokenResult = await safe(() => context.auth.api.getAccessToken({ body: { - accountId: integration.accountId, + accountId: integration.accountId ?? undefined, userId: integration.account?.userId, providerId: "google", }, From 4f77158482e68e2d0d8a03e16f468cbc5720847c Mon Sep 17 00:00:00 2001 From: ElasticBottle Date: Sun, 1 Feb 2026 13:56:26 +0800 Subject: [PATCH 11/60] chore: update content schemas [still a little broken] --- .../src/lib/content/ensure-draft-for-slug.ts | 49 +++---------- .../content/get-contents-by-slug-prefix.ts | 3 +- .../src/lib/content/write-content-draft.ts | 19 +++-- packages/api-seo/src/routes/content.ts | 27 ++++--- .../src/operations/seo/content-operations.ts | 72 +++++++++++++++---- .../db/src/schema/seo/content-draft-schema.ts | 21 ++---- packages/db/src/schema/seo/content-schema.ts | 11 +++ 7 files changed, 111 insertions(+), 91 deletions(-) diff --git a/packages/api-seo/src/lib/content/ensure-draft-for-slug.ts b/packages/api-seo/src/lib/content/ensure-draft-for-slug.ts index 15d41519..a24eebd8 100644 --- a/packages/api-seo/src/lib/content/ensure-draft-for-slug.ts +++ b/packages/api-seo/src/lib/content/ensure-draft-for-slug.ts @@ -1,8 +1,8 @@ import type { DB, schema } from "@rectangular-labs/db"; import { createContentDraft, - getContentBySlug, getDraftBySlug, + hasPublishedSnapshotForDraft, } from "@rectangular-labs/db/operations"; import { err, ok, type Result } from "@rectangular-labs/result"; @@ -33,47 +33,20 @@ export async function ensureDraftForSlug(args: { return existingDraftResult; } if (existingDraftResult.value) { - return ok({ draft: existingDraftResult.value, isNew: false }); - } - - // Check if there's existing live content for this slug - const liveResult = await getContentBySlug({ - db: args.db, - organizationId: args.organizationId, - projectId: args.projectId, - slug: args.slug, - withContent: true, - }); - if (!liveResult.ok) { - return liveResult; - } - const live = liveResult.value; - - if (live) { - // Create draft from live content (for editing existing published content) - const draftResult = await createContentDraft(args.db, { - organizationId: args.organizationId, - projectId: args.projectId, - title: live.title, - description: live.description, - slug: args.slug, - primaryKeyword: live.primaryKeyword, - notes: live.notes, - outline: live.outline, - articleType: live.articleType, - contentMarkdown: live.contentMarkdown, - baseContentId: live.id, - // Schedule for now - it's an update to existing content - scheduledFor: new Date(), + const snapshotResult = await hasPublishedSnapshotForDraft({ + db: args.db, + draftId: existingDraftResult.value.id, }); - if (!draftResult.ok) { - return draftResult; + if (!snapshotResult.ok) { + return snapshotResult; } - - return ok({ draft: draftResult.value, isNew: true }); + return ok({ + draft: existingDraftResult.value, + isNew: !snapshotResult.value, + }); } - // No live content - create brand new draft + // no existing draft, create brand new draft if (!args.primaryKeyword) { return err(new Error("Primary keyword is required to create a new draft.")); } diff --git a/packages/api-seo/src/lib/content/get-contents-by-slug-prefix.ts b/packages/api-seo/src/lib/content/get-contents-by-slug-prefix.ts index a3c69306..50ed387b 100644 --- a/packages/api-seo/src/lib/content/get-contents-by-slug-prefix.ts +++ b/packages/api-seo/src/lib/content/get-contents-by-slug-prefix.ts @@ -70,10 +70,11 @@ export async function getContentsBySlugPrefix(args: { // Add drafts for (const draft of draftBySlug.values()) { + const liveForSlug = liveBySlug.get(draft.slug); entries.push({ slug: draft.slug, status: draft.status, - contentId: draft.baseContentId ?? undefined, + contentId: liveForSlug?.id, }); } diff --git a/packages/api-seo/src/lib/content/write-content-draft.ts b/packages/api-seo/src/lib/content/write-content-draft.ts index b2ce0ca9..050ba544 100644 --- a/packages/api-seo/src/lib/content/write-content-draft.ts +++ b/packages/api-seo/src/lib/content/write-content-draft.ts @@ -7,6 +7,7 @@ import { getDraftById, getScheduledItems, getSeoProjectByIdentifierAndOrgId, + hasPublishedSnapshotForDraft, updateContentDraft, validateSlug, } from "@rectangular-labs/db/operations"; @@ -55,8 +56,6 @@ export async function writeContentDraft( } let draft: typeof schema.seoContentDraft.$inferSelect; - let isNew = false; - if (args.lookup.type === "id") { const draftResult = await getDraftById({ db: args.db, @@ -80,7 +79,6 @@ export async function writeContentDraft( }); if (!draftResult.ok) return draftResult; draft = draftResult.value.draft; - isNew = draftResult.value.isNew; } // Record attribution @@ -112,7 +110,7 @@ export async function writeContentDraft( projectId: args.projectId, slug: nextSlug, ignoreDraftId: draft.id, - ignoreContentId: draft.baseContentId ?? undefined, + ignoreOriginatingDraftId: draft.id, }); if (!slugValidation.ok) return slugValidation; if (!slugValidation.value.valid) { @@ -199,12 +197,21 @@ export async function writeContentDraft( } } + const hasPublishedSnapshotResult = await hasPublishedSnapshotForDraft({ + db: args.db, + draftId: draft.id, + }); + if (!hasPublishedSnapshotResult.ok) { + return hasPublishedSnapshotResult; + } + const hasPublishedSnapshot = hasPublishedSnapshotResult.value; + if (nextStatus === "scheduled") { // Determine scheduled publish time let scheduledFor = updatedDraft.scheduledFor; // new content should be scheduled - if (!scheduledFor && !draft.baseContentId) { + if (!scheduledFor && !hasPublishedSnapshot) { const projectResult = await getSeoProjectByIdentifierAndOrgId( args.db, args.projectId, @@ -298,5 +305,5 @@ export async function writeContentDraft( }); } - return ok({ draft: updatedDraft, isNew }); + return ok({ draft: updatedDraft, isNew: !hasPublishedSnapshot }); } diff --git a/packages/api-seo/src/routes/content.ts b/packages/api-seo/src/routes/content.ts index e9a5f947..20b4f3cd 100644 --- a/packages/api-seo/src/routes/content.ts +++ b/packages/api-seo/src/routes/content.ts @@ -10,7 +10,6 @@ import { getDraftById, getNextVersionForSlug, getSeoProjectByIdentifierAndOrgId, - hardDeleteDraft, listDraftsByStatus, listDraftsForExport, listPublishedContent, @@ -62,12 +61,12 @@ const listDrafts = withOrganizationIdBase }), ) .handler(async ({ context, input }) => { - const hasBaseContentId = input.isNew === null ? null : !input.isNew; + const hasPublishedSnapshot = input.isNew === null ? null : !input.isNew; const rowsResult = await listDraftsByStatus({ db: context.db, organizationId: context.organization.id, projectId: input.projectId, - hasBaseContentId, + hasPublishedSnapshot, status: input.status, cursor: input.cursor, limit: input.limit + 1, @@ -161,21 +160,21 @@ const getReviewCounts = withOrganizationIdBase db: context.db, organizationId: context.organization.id, projectId: input.projectId, - hasBaseContentId: false, + hasPublishedSnapshot: false, status: "suggested", }), countDraftsByStatus({ db: context.db, organizationId: context.organization.id, projectId: input.projectId, - hasBaseContentId: false, + hasPublishedSnapshot: false, status: reviewStatuses, }), countDraftsByStatus({ db: context.db, organizationId: context.organization.id, projectId: input.projectId, - hasBaseContentId: true, + hasPublishedSnapshot: true, status: reviewStatuses, }), ]); @@ -514,7 +513,6 @@ const publishContent = base scheduledFor: draft.scheduledFor, now, }); - // TODO(publication): send a webhook to the publish destination // Get next version number for this slug const nextVersionResult = await getNextVersionForSlug({ @@ -540,6 +538,7 @@ const publishContent = base const createdContentResult = await createContent(context.db, { organizationId: draft.organizationId, projectId: draft.projectId, + originatingDraftId: draft.id, slug: draft.slug, version: nextVersion, title: draft.title, @@ -572,16 +571,16 @@ const publishContent = base }); } - const deletedDraftResult = await hardDeleteDraft({ - db: context.db, - organizationId: draft.organizationId, - projectId: draft.projectId, + const updatedDraftResult = await updateContentDraft(context.db, { id: draft.id, + projectId: draft.projectId, + organizationId: draft.organizationId, + status: "published", }); - if (!deletedDraftResult.ok) { + if (!updatedDraftResult.ok) { throw new ORPCError("INTERNAL_SERVER_ERROR", { - message: "Failed to delete draft after publishing.", - cause: deletedDraftResult.error, + message: "Failed to update draft after publishing.", + cause: updatedDraftResult.error, }); } diff --git a/packages/db/src/operations/seo/content-operations.ts b/packages/db/src/operations/seo/content-operations.ts index a87962ab..1bdac05a 100644 --- a/packages/db/src/operations/seo/content-operations.ts +++ b/packages/db/src/operations/seo/content-operations.ts @@ -6,10 +6,11 @@ import { type DBTransaction, desc, eq, + exists, inArray, - isNotNull, isNull, lt, + notExists, schema, } from "../../client"; import type { @@ -124,6 +125,7 @@ export async function listPublishedContent(args: { primaryKeyword: schema.seoContent.primaryKeyword, articleType: schema.seoContent.articleType, publishedAt: schema.seoContent.publishedAt, + originatingDraftId: schema.seoContent.originatingDraftId, createdAt: schema.seoContent.createdAt, updatedAt: schema.seoContent.updatedAt, deletedAt: schema.seoContent.deletedAt, @@ -280,6 +282,24 @@ export async function updateContentDraft( return ok(updatedDraft); } +export async function hasPublishedSnapshotForDraft(args: { + db: DB; + draftId: string; +}) { + const result = await safe(() => + args.db.query.seoContent.findFirst({ + columns: { id: true }, + where: (table, { and, eq, isNull }) => + and( + eq(table.originatingDraftId, args.draftId), + isNull(table.deletedAt), + ), + }), + ); + if (!result.ok) return result; + return ok(Boolean(result.value)); +} + export async function hardDeleteDraft(args: { db: DB; organizationId: string; @@ -391,7 +411,7 @@ export async function listDraftsByStatus(args: { db: DB; organizationId: string; projectId: string; - hasBaseContentId: boolean | null; + hasPublishedSnapshot: boolean | null; status: SeoFileStatus | readonly SeoFileStatus[]; cursor: string | undefined; limit: number; @@ -399,16 +419,27 @@ export async function listDraftsByStatus(args: { return await safe(() => args.db.query.seoContentDraft.findMany({ columns: { contentMarkdown: false, outline: false, notes: false }, - where: (table, { and, eq, inArray, isNull, lt, isNotNull }) => + where: (table, { and, eq, inArray, isNull, lt }) => and( eq(table.organizationId, args.organizationId), eq(table.projectId, args.projectId), isNull(table.deletedAt), - args.hasBaseContentId === null + args.hasPublishedSnapshot === null ? undefined - : args.hasBaseContentId - ? isNotNull(table.baseContentId) - : isNull(table.baseContentId), + : (() => { + const publishedSnapshotQuery = args.db + .select({ id: schema.seoContent.id }) + .from(schema.seoContent) + .where( + and( + eq(schema.seoContent.originatingDraftId, table.id), + isNull(schema.seoContent.deletedAt), + ), + ); + return args.hasPublishedSnapshot + ? exists(publishedSnapshotQuery) + : notExists(publishedSnapshotQuery); + })(), typeof args.status === "string" ? eq(table.status, args.status) : inArray(table.status, [...args.status]), @@ -472,6 +503,7 @@ export async function listPublishedContentForExport(args: { articleType: schema.seoContent.articleType, contentMarkdown: schema.seoContent.contentMarkdown, publishedAt: schema.seoContent.publishedAt, + originatingDraftId: schema.seoContent.originatingDraftId, createdAt: schema.seoContent.createdAt, updatedAt: schema.seoContent.updatedAt, deletedAt: schema.seoContent.deletedAt, @@ -500,17 +532,26 @@ export async function countDraftsByStatus(args: { db: DB; organizationId: string; projectId: string; - hasBaseContentId: boolean; + hasPublishedSnapshot: boolean; status: SeoFileStatus | readonly SeoFileStatus[]; }) { return await safe(async () => { + const publishedSnapshotQuery = args.db + .select({ id: schema.seoContent.id }) + .from(schema.seoContent) + .where( + and( + eq(schema.seoContent.originatingDraftId, schema.seoContentDraft.id), + isNull(schema.seoContent.deletedAt), + ), + ); const where = and( eq(schema.seoContentDraft.organizationId, args.organizationId), eq(schema.seoContentDraft.projectId, args.projectId), isNull(schema.seoContentDraft.deletedAt), - args.hasBaseContentId - ? isNotNull(schema.seoContentDraft.baseContentId) - : isNull(schema.seoContentDraft.baseContentId), + args.hasPublishedSnapshot + ? exists(publishedSnapshotQuery) + : notExists(publishedSnapshotQuery), typeof args.status === "string" ? eq(schema.seoContentDraft.status, args.status) : inArray(schema.seoContentDraft.status, [...args.status]), @@ -531,7 +572,7 @@ export async function validateSlug(args: { organizationId: string; projectId: string; slug: string; - ignoreContentId: string | undefined; + ignoreOriginatingDraftId: string | undefined; ignoreDraftId: string | undefined; }) { const [publishedResult, draftResult] = await Promise.all([ @@ -539,13 +580,14 @@ export async function validateSlug(args: { safe(() => args.db.query.seoContent.findFirst({ columns: { id: true }, - where: (table, { and, eq, ne }) => + where: (table, { and, eq, ne, isNull }) => and( eq(table.organizationId, args.organizationId), eq(table.projectId, args.projectId), eq(table.slug, args.slug), - args.ignoreContentId - ? ne(table.id, args.ignoreContentId) + isNull(table.deletedAt), + args.ignoreOriginatingDraftId + ? ne(table.originatingDraftId, args.ignoreOriginatingDraftId) : undefined, ), }), diff --git a/packages/db/src/schema/seo/content-draft-schema.ts b/packages/db/src/schema/seo/content-draft-schema.ts index de455db2..50264e47 100644 --- a/packages/db/src/schema/seo/content-draft-schema.ts +++ b/packages/db/src/schema/seo/content-draft-schema.ts @@ -44,10 +44,6 @@ export const seoContentDraft = pgSeoTable( onDelete: "cascade", onUpdate: "cascade", }), - baseContentId: uuid().references(() => seoContent.id, { - onDelete: "cascade", - onUpdate: "cascade", - }), strategyId: uuid().references(() => seoStrategy.id, { onDelete: "set null", onUpdate: "cascade", @@ -63,8 +59,8 @@ export const seoContentDraft = pgSeoTable( heroImageCaption: text(), primaryKeyword: text().notNull().default(""), articleType: text({ enum: ARTICLE_TYPES }), - outline: text(), contentMarkdown: text(), + outline: text(), notes: text(), status: text({ enum: CONTENT_STATUSES }).notNull().default("suggested"), @@ -96,14 +92,8 @@ export const seoContentDraft = pgSeoTable( sql`${table.slug} text_pattern_ops`, ) .where(isNull(table.deletedAt)), - index("seo_content_draft_org_project_status_base_id_idx") - .on( - table.organizationId, - table.projectId, - table.status, - table.baseContentId, - table.id, - ) + index("seo_content_draft_org_project_status_idx") + .on(table.organizationId, table.projectId, table.status, table.id) .where(isNull(table.deletedAt)), ], ); @@ -119,10 +109,6 @@ export const seoContentDraftRelations = relations( fields: [seoContentDraft.organizationId], references: [organization.id], }), - baseContent: one(seoContent, { - fields: [seoContentDraft.baseContentId], - references: [seoContent.id], - }), strategy: one(seoStrategy, { fields: [seoContentDraft.strategyId], references: [seoStrategy.id], @@ -135,6 +121,7 @@ export const seoContentDraftRelations = relations( fields: [seoContentDraft.generatedByTaskRunId], references: [seoTaskRun.id], }), + contentSnapshot: many(seoContent), metricSnapshot: many(seoStrategySnapshotContent), phaseContent: many(seoStrategyPhaseContent), // Attribution join tables diff --git a/packages/db/src/schema/seo/content-schema.ts b/packages/db/src/schema/seo/content-schema.ts index 51579f88..1b321c20 100644 --- a/packages/db/src/schema/seo/content-schema.ts +++ b/packages/db/src/schema/seo/content-schema.ts @@ -17,6 +17,7 @@ import { import { timestamps, uuidv7 } from "../_helper"; import { pgSeoTable } from "../_table"; import { organization } from "../auth-schema"; +import { seoContentDraft } from "./content-draft-schema"; import { seoProject } from "./project-schema"; /** @@ -42,6 +43,12 @@ export const seoContent = pgSeoTable( onDelete: "cascade", onUpdate: "cascade", }), + originatingDraftId: uuid() + .notNull() + .references(() => seoContentDraft.id, { + onDelete: "cascade", + onUpdate: "cascade", + }), // Versioning - each publish increments version for the same slug slug: text().notNull(), @@ -84,6 +91,10 @@ export const seoContent = pgSeoTable( ); export const seoContentRelations = relations(seoContent, ({ one }) => ({ + originatingDraft: one(seoContentDraft, { + fields: [seoContent.originatingDraftId], + references: [seoContentDraft.id], + }), project: one(seoProject, { fields: [seoContent.projectId], references: [seoProject.id], From 18da9f9e45c18a829cb3d78923a8526f35717161 Mon Sep 17 00:00:00 2001 From: ElasticBottle Date: Sun, 1 Feb 2026 15:02:52 +0800 Subject: [PATCH 12/60] chore: clean up ui chore: fix type error from reading cadence from the wrong place --- .../-components/1-user-background.tsx | 240 +++++++++--------- .../-components/2-create-organization.tsx | 110 ++++---- .../onboarding/-components/3-website-info.tsx | 110 ++++---- .../-components/connect-publishing.tsx | 74 +++--- .../onboarding/-components/content.tsx | 7 + .../routes/_authed/onboarding/-lib/steps.ts | 5 + .../src/lib/content/write-content-draft.ts | 65 ++--- .../src/operations/seo/strategy-operations.ts | 23 +- 8 files changed, 322 insertions(+), 312 deletions(-) diff --git a/apps/seo/src/routes/_authed/onboarding/-components/1-user-background.tsx b/apps/seo/src/routes/_authed/onboarding/-components/1-user-background.tsx index 40ac9097..93bc2f6c 100644 --- a/apps/seo/src/routes/_authed/onboarding/-components/1-user-background.tsx +++ b/apps/seo/src/routes/_authed/onboarding/-components/1-user-background.tsx @@ -119,153 +119,151 @@ export function OnboardingUserBackground({ }; return ( -
- - - {title} - {description} - -
- - + + + {title} + {description} + + + + + ( + + + Where did you first hear about us? + + + {fieldState.invalid && ( + + )} + + )} + /> + + {isOtherSource && ( ( - - Where did you first hear about us? + + Other Source - + {fieldState.invalid && ( )} )} /> + )} - {isOtherSource && ( - ( - - - Other Source - - - {fieldState.invalid && ( - - )} - + ( + + + Where would you want Fluid Posts to help with the most? + + + {fieldState.invalid && ( + )} - /> + )} + /> + {isOtherGoal && ( ( - - Where would you want Fluid Posts to help with the most? + + Other Goal - + {fieldState.invalid && ( )} )} /> - - {isOtherGoal && ( - ( - - - Other Goal - - - {fieldState.invalid && ( - - )} - - )} - /> - )} - - - {form.formState.errors.root && ( - )} - - -
- - -
-
- -
-
+ + + {form.formState.errors.root && ( + + )} + + +
+ + +
+
+ + ); } diff --git a/apps/seo/src/routes/_authed/onboarding/-components/2-create-organization.tsx b/apps/seo/src/routes/_authed/onboarding/-components/2-create-organization.tsx index fb398955..01d4d7ac 100644 --- a/apps/seo/src/routes/_authed/onboarding/-components/2-create-organization.tsx +++ b/apps/seo/src/routes/_authed/onboarding/-components/2-create-organization.tsx @@ -90,62 +90,60 @@ export function OnboardingCreateOrganization() { }; return ( -
- - - Set Up Organization - - Your organization will let you manage team members and projects. - - -
- - - ( - - - Organization Name - - - - You will be able to change this at anytime later on - - {fieldState.invalid && ( - - )} - - )} - /> - + + + Set Up Organization + + Your organization will let you manage team members and projects. + + + + + + ( + + + Organization Name + + + + You will be able to change this at anytime later on + + {fieldState.invalid && ( + + )} + + )} + /> + - {form.formState.errors.root && ( - - )} - - -
- - -
-
- -
-
+ {form.formState.errors.root && ( + + )} + + +
+ + +
+
+ + ); } diff --git a/apps/seo/src/routes/_authed/onboarding/-components/3-website-info.tsx b/apps/seo/src/routes/_authed/onboarding/-components/3-website-info.tsx index f36795c9..62e61459 100644 --- a/apps/seo/src/routes/_authed/onboarding/-components/3-website-info.tsx +++ b/apps/seo/src/routes/_authed/onboarding/-components/3-website-info.tsx @@ -91,65 +91,63 @@ export function OnboardingWebsiteInfo({ }; return ( -
- - - {title} - {description} - -
- - - ( - - - Website - - - {fieldState.invalid && ( - - )} - - )} - /> - - - {form.formState.errors.root && ( - - )} - - -
- {type === "new-user" && ( - + + + {title} + {description} + + + + + ( + + + Website + + + {fieldState.invalid && ( + + )} + )} + /> + + + {form.formState.errors.root && ( + + )} + + +
+ {type === "new-user" && ( -
-
- -
-
+ )} + +
+ + + ); } diff --git a/apps/seo/src/routes/_authed/onboarding/-components/connect-publishing.tsx b/apps/seo/src/routes/_authed/onboarding/-components/connect-publishing.tsx index cfcdbb2d..c5b41819 100644 --- a/apps/seo/src/routes/_authed/onboarding/-components/connect-publishing.tsx +++ b/apps/seo/src/routes/_authed/onboarding/-components/connect-publishing.tsx @@ -70,45 +70,43 @@ export function OnboardingConnectPublishing({ ); return ( -
- - - {title} - {description} - - - {(projectLoading || integrationsLoading) && ( -
- Loading publishing options... -
- )} + + + {title} + {description} + + + {(projectLoading || integrationsLoading) && ( +
+ Loading publishing options... +
+ )} - {!projectLoading && !integrationsLoading && !project && ( -
- Missing project details. Please go back and try again. -
- )} + {!projectLoading && !integrationsLoading && !project && ( +
+ Missing project details. Please go back and try again. +
+ )} - {!projectLoading && !integrationsLoading && project && ( - stepper.next()} - organizationId={project.organizationId ?? ""} - projectId={project.id} - providers={publishingProviders} - /> - )} -
- - - - -
-
+ {!projectLoading && !integrationsLoading && project && ( + stepper.next()} + organizationId={project.organizationId ?? ""} + projectId={project.id} + providers={publishingProviders} + /> + )} + + + + + + ); } diff --git a/apps/seo/src/routes/_authed/onboarding/-components/content.tsx b/apps/seo/src/routes/_authed/onboarding/-components/content.tsx index d94c8b9a..beacd294 100644 --- a/apps/seo/src/routes/_authed/onboarding/-components/content.tsx +++ b/apps/seo/src/routes/_authed/onboarding/-components/content.tsx @@ -9,6 +9,7 @@ import { OnboardingConnectGsc } from "./connect-gsc"; import { OnboardingConnectGscProperty } from "./connect-gsc-property"; import { OnboardingConnectPublishing } from "./connect-publishing"; import { OnboardingProgress } from "./onboarding-progress"; +import { OnboardingStrategyInsights } from "./strategy-insights"; export function OnboardingContent() { const matcher = OnboardingSteps.useStepper(); @@ -62,6 +63,12 @@ export function OnboardingContent() { title={step.title} /> ), + "strategy-insights": (step) => ( + + ), "all-set": (step) => ( + item.scheduledFor !== null, + ), }); - if (scheduledItemsResult.ok) { - const scheduledItems = scheduledItemsResult.value; - const scheduledIso = computeNextAvailableScheduleIso({ - cadence, - scheduledItems: scheduledItems.filter( - (item): item is { scheduledFor: Date } => - item.scheduledFor !== null, - ), - }); - if (scheduledIso) { - scheduledFor = new Date(scheduledIso); - } + if (scheduledIso) { + scheduledFor = new Date(scheduledIso); } } } diff --git a/packages/db/src/operations/seo/strategy-operations.ts b/packages/db/src/operations/seo/strategy-operations.ts index 7ef14ba5..30876191 100644 --- a/packages/db/src/operations/seo/strategy-operations.ts +++ b/packages/db/src/operations/seo/strategy-operations.ts @@ -9,7 +9,7 @@ import type { } from "../../schema/seo"; export async function listStrategiesByProjectId(args: { - db: DB | DBTransaction; + db: DB; projectId: string; }) { return await safe(() => @@ -169,3 +169,24 @@ export async function updateStrategyPhase( } return ok(phase); } + +export async function getCurrentStrategyPhase(args: { + db: DB; + strategyId: string; +}) { + const result = await safe(() => + args.db.query.seoStrategyPhase.findFirst({ + where: (table, { eq, isNull, and }) => + and(eq(table.strategyId, args.strategyId), isNull(table.deletedAt)), + orderBy: (fields, { desc }) => [desc(fields.createdAt)], + }), + ); + if (!result.ok) { + return result; + } + const phase = result.value; + if (!phase) { + return err(new Error("Failed to find strategy phase")); + } + return ok(phase); +} From b33ddb19370fd93abdfdf8595693c8b3bb4e32ef Mon Sep 17 00:00:00 2001 From: ElasticBottle Date: Sun, 1 Feb 2026 15:27:02 +0800 Subject: [PATCH 13/60] chore(api-seo): make strategy-suggestion workflow more generic --- .../src/lib/ai/tools/strategy-tools.ts | 72 +------------------ .../src/workflows/onboarding-workflow.ts | 15 ++++ .../strategy-suggestions-workflow.ts | 55 +++++++++++--- ...arding-strategy-suggestion-instructions.ts | 7 ++ packages/core/src/schemas/task-parsers.ts | 1 + .../src/operations/seo/project-operations.ts | 1 + packages/db/src/schema/seo/project-schema.ts | 14 ++++ 7 files changed, 85 insertions(+), 80 deletions(-) create mode 100644 packages/core/src/ai/onboarding-strategy-suggestion-instructions.ts diff --git a/packages/api-seo/src/lib/ai/tools/strategy-tools.ts b/packages/api-seo/src/lib/ai/tools/strategy-tools.ts index d7e0ba5e..505d486b 100644 --- a/packages/api-seo/src/lib/ai/tools/strategy-tools.ts +++ b/packages/api-seo/src/lib/ai/tools/strategy-tools.ts @@ -1,18 +1,9 @@ import type { DB } from "@rectangular-labs/db"; -import { - getStrategyDetails, - listStrategiesByProjectId, -} from "@rectangular-labs/db/operations"; +import { getStrategyDetails } from "@rectangular-labs/db/operations"; import { type JSONSchema7, jsonSchema, tool } from "ai"; import { type } from "arktype"; import type { AgentToolDefinition } from "./utils"; -const listStrategiesInputSchema = type({ - "includeSuggestions?": "boolean", - "includeArchived?": "boolean", - "limit?": "number", -}); - const strategyDetailsInputSchema = type({ strategyId: "string.uuid", "snapshotLimit?": "number", @@ -22,58 +13,6 @@ export function createStrategyToolsWithMetadata(args: { db: DB; projectId: string; }) { - const listStrategies = tool({ - description: - "List existing strategies for the project in a compact, token-efficient format.", - inputSchema: jsonSchema( - listStrategiesInputSchema.toJsonSchema() as JSONSchema7, - ), - async execute({ - includeSuggestions = false, - includeArchived = false, - limit, - }) { - const result = await listStrategiesByProjectId({ - db: args.db, - projectId: args.projectId, - }); - if (!result.ok) { - return { success: false, message: result.error.message }; - } - - const filtered = result.value.filter((strategy) => { - if (!includeSuggestions && strategy.status === "suggestion") { - return false; - } - if (!includeArchived && strategy.status === "dismissed") { - return false; - } - return true; - }); - - const limited = - typeof limit === "number" - ? filtered.slice(0, Math.max(0, limit)) - : filtered; - const strategies = limited.map((strategy) => ({ - id: strategy.id, - name: strategy.name, - status: strategy.status, - goal: strategy.goal, - updatedAt: - strategy.updatedAt?.toISOString?.() ?? - String(strategy.updatedAt ?? ""), - phaseCount: strategy.phases?.length ?? 0, - })); - - return { - success: true, - count: strategies.length, - strategies, - }; - }, - }); - const getStrategyDetailsTool = tool({ description: "Fetch detailed strategy information, including phases, phase contents, and recent snapshots.", @@ -123,19 +62,10 @@ export function createStrategyToolsWithMetadata(args: { }); const tools = { - list_strategies: listStrategies, get_strategy_details: getStrategyDetailsTool, } as const; const toolDefinitions: AgentToolDefinition[] = [ - { - toolName: "list_strategies", - toolDescription: - "List existing project strategies in a compact format (id, name, status, goal, phase count).", - toolInstruction: - "Use first to avoid duplicating active strategies. Default excludes suggestions/archived unless needed. Limit results for token efficiency.", - tool: listStrategies, - }, { toolName: "get_strategy_details", toolDescription: diff --git a/packages/api-seo/src/workflows/onboarding-workflow.ts b/packages/api-seo/src/workflows/onboarding-workflow.ts index b493edd9..8ae33ae8 100644 --- a/packages/api-seo/src/workflows/onboarding-workflow.ts +++ b/packages/api-seo/src/workflows/onboarding-workflow.ts @@ -21,6 +21,7 @@ import { stepCountIs, } from "ai"; import { type } from "arktype"; +import { ONBOARDING_STRATEGY_SUGGESTION_INSTRUCTIONS } from "../../../core/dist/ai/onboarding-strategy-suggestion-instructions"; import { createWebToolsWithMetadata } from "../lib/ai/tools/web-tools"; import { createTask } from "../lib/task"; import { DEFAULT_BRAND_VOICE } from "../lib/workspace/workflow.constant"; @@ -220,6 +221,7 @@ Extract the name from the above context.`, input: { type: "seo-generate-strategy-suggestions", projectId: project.id, + instructions: ONBOARDING_STRATEGY_SUGGESTION_INSTRUCTIONS, }, workflowInstanceId: `strategy_${event.instanceId}_${crypto.randomUUID().slice(0, 5)}`, }); @@ -228,6 +230,19 @@ Extract the name from the above context.`, projectId: project.id, error: taskResult.error, }); + return; + } + + const updateResult = await updateSeoProject(db, { + id: project.id, + organizationId: project.organizationId, + strategySuggestionsWorkflowId: taskResult.value.id, + }); + if (!updateResult.ok) { + logError("failed to save strategy suggestions workflow id", { + projectId: project.id, + error: updateResult.error, + }); } }); diff --git a/packages/api-seo/src/workflows/strategy-suggestions-workflow.ts b/packages/api-seo/src/workflows/strategy-suggestions-workflow.ts index 2b83410a..7e083ccb 100644 --- a/packages/api-seo/src/workflows/strategy-suggestions-workflow.ts +++ b/packages/api-seo/src/workflows/strategy-suggestions-workflow.ts @@ -15,6 +15,7 @@ import { createDb } from "@rectangular-labs/db"; import { createStrategies, getSeoProjectById, + listStrategiesByProjectId, } from "@rectangular-labs/db/operations"; import { generateText, @@ -131,21 +132,57 @@ export class SeoStrategySuggestionsWorkflow extends WorkflowEntrypoint< siteType: gscIntegrationResult.value?.config?.propertyType ?? null, }); - const system = `You are an SEO strategist generating onboarding strategy suggestions. + const existingStrategiesResult = await listStrategiesByProjectId({ + db, + projectId: project.id, + }); + if (!existingStrategiesResult.ok) { + throw existingStrategiesResult.error; + } + + const existingStrategies = + existingStrategiesResult.value.length > 0 + ? existingStrategiesResult.value + .map((strategy) => { + const goal = strategy.goal + ? `${strategy.goal.target} ${strategy.goal.metric} ${strategy.goal.timeframe}` + : "none"; + const updatedAt = strategy.updatedAt + ?.toISOString?.() + ?.slice(0, 10); + const dismissalReason = strategy.dismissalReason + ? `dismissal reason: ${strategy.dismissalReason}` + : ""; + return [ + `- [${strategy.status}] "${strategy.name}"`, + `id: ${strategy.id}`, + `goal: ${goal}`, + `phases:${strategy.phases?.length ?? 0}`, + `updated:${updatedAt ?? "unknown"}`, + dismissalReason, + ] + .filter(Boolean) + .join("|"); + }) + .join("\n") + : "- none"; + + const system = `You are an SEO strategist generating strategy suggestions. + +## Objective +- Propose strategy suggestions that fit the project's context and current work. +- Avoid duplicates by name and intent. Learn from dismissed strategies and their reasons. -## Task -- Generate exactly 2 strategy suggestions for the project. -- Each suggestion must include: name, motivation, description, goal. +## Instructions +${input.instructions} ## Data Usage - Use available tools (Google Search Console, DataForSEO, web search) directly to ground recommendations. -- Always check existing strategies via list_strategies and avoid duplicating active strategies. -- If you need more context, use get_strategy_details for relevant strategies. +- If you need more context about a specific strategy, use get_strategy_details. - If Google Search Console is not available, rely on competitor and keyword tools plus public site info. -## Suggestion Mix Rules -- If GSC data is available, include at least one "improve existing content" strategy. -- Always include one "create new content cluster" strategy. +## Existing Strategies (compact) +${existingStrategies} ## Output Requirements - Keep each strategy concise and actionable. diff --git a/packages/core/src/ai/onboarding-strategy-suggestion-instructions.ts b/packages/core/src/ai/onboarding-strategy-suggestion-instructions.ts new file mode 100644 index 00000000..f5bf490a --- /dev/null +++ b/packages/core/src/ai/onboarding-strategy-suggestion-instructions.ts @@ -0,0 +1,7 @@ +export const ONBOARDING_STRATEGY_SUGGESTION_INSTRUCTIONS = [ + "Generate exactly 2 strategy suggestions for the project.", + "", + "Suggestion Mix Rules:", + '- If GSC data is available, include at least one "improve existing content" strategy.', + '- Always include one "create new content cluster" strategy.', +].join("\n"); diff --git a/packages/core/src/schemas/task-parsers.ts b/packages/core/src/schemas/task-parsers.ts index e1d20741..a5253396 100644 --- a/packages/core/src/schemas/task-parsers.ts +++ b/packages/core/src/schemas/task-parsers.ts @@ -16,6 +16,7 @@ export const seoUnderstandSiteTaskInputSchema = type({ export const seoStrategySuggestionsTaskInputSchema = type({ type: "'seo-generate-strategy-suggestions'", projectId: "string.uuid", + instructions: "string", }); export const seoPlanKeywordTaskInputSchema = type({ diff --git a/packages/db/src/operations/seo/project-operations.ts b/packages/db/src/operations/seo/project-operations.ts index 3dd7cec9..91853578 100644 --- a/packages/db/src/operations/seo/project-operations.ts +++ b/packages/db/src/operations/seo/project-operations.ts @@ -135,6 +135,7 @@ export async function getSeoProjectByIdentifierAndOrgId< organizationId: true, deletedAt: true, projectResearchWorkflowId: true, + strategySuggestionsWorkflowId: true, ...(includeSettings ?? {}), }, where: (table, { eq, and, isNull }) => diff --git a/packages/db/src/schema/seo/project-schema.ts b/packages/db/src/schema/seo/project-schema.ts index cfc9fe6d..04714eac 100644 --- a/packages/db/src/schema/seo/project-schema.ts +++ b/packages/db/src/schema/seo/project-schema.ts @@ -54,6 +54,13 @@ export const seoProject = pgSeoTable( onUpdate: "cascade", }, ), + strategySuggestionsWorkflowId: uuid().references( + (): AnyPgColumn => seoTaskRun.id, + { + onDelete: "set null", + onUpdate: "cascade", + }, + ), ...timestamps, }, (table) => [ @@ -67,6 +74,9 @@ export const seoProject = pgSeoTable( index("seo_project_research_workflow_idx").on( table.projectResearchWorkflowId, ), + index("seo_project_strategy_suggestions_workflow_idx").on( + table.strategySuggestionsWorkflowId, + ), ], ); @@ -82,6 +92,10 @@ export const seoProjectRelations = relations(seoProject, ({ one, many }) => ({ fields: [seoProject.projectResearchWorkflowId], references: [seoTaskRun.id], }), + strategySuggestionsWorkflow: one(seoTaskRun, { + fields: [seoProject.strategySuggestionsWorkflowId], + references: [seoTaskRun.id], + }), })); export const seoProjectInsertSchema = createInsertSchema(seoProject).omit( From d29c9964d265cda497798db9e29fad8f103babb2 Mon Sep 17 00:00:00 2001 From: ElasticBottle Date: Sun, 1 Feb 2026 15:33:25 +0800 Subject: [PATCH 14/60] fix: import error --- packages/api-seo/src/workflows/onboarding-workflow.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api-seo/src/workflows/onboarding-workflow.ts b/packages/api-seo/src/workflows/onboarding-workflow.ts index 8ae33ae8..3fd666c1 100644 --- a/packages/api-seo/src/workflows/onboarding-workflow.ts +++ b/packages/api-seo/src/workflows/onboarding-workflow.ts @@ -6,6 +6,7 @@ import { import { NonRetryableError } from "cloudflare:workflows"; import { google } from "@ai-sdk/google"; import { openai } from "@ai-sdk/openai"; +import { ONBOARDING_STRATEGY_SUGGESTION_INSTRUCTIONS } from "@rectangular-labs/core/ai/onboarding-strategy-suggestion-instructions"; import { toSlug } from "@rectangular-labs/core/format/to-slug"; import { type seoUnderstandSiteTaskInputSchema, @@ -21,7 +22,6 @@ import { stepCountIs, } from "ai"; import { type } from "arktype"; -import { ONBOARDING_STRATEGY_SUGGESTION_INSTRUCTIONS } from "../../../core/dist/ai/onboarding-strategy-suggestion-instructions"; import { createWebToolsWithMetadata } from "../lib/ai/tools/web-tools"; import { createTask } from "../lib/task"; import { DEFAULT_BRAND_VOICE } from "../lib/workspace/workflow.constant"; From cafda048ba25dc83039eca2515635f535a0f18bc Mon Sep 17 00:00:00 2001 From: ElasticBottle Date: Sun, 1 Feb 2026 15:41:03 +0800 Subject: [PATCH 15/60] chore(db): add strategy dismissal reason --- packages/db/src/schema/seo/strategy-schema.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/db/src/schema/seo/strategy-schema.ts b/packages/db/src/schema/seo/strategy-schema.ts index f5475dde..bff681c9 100644 --- a/packages/db/src/schema/seo/strategy-schema.ts +++ b/packages/db/src/schema/seo/strategy-schema.ts @@ -30,6 +30,7 @@ export const seoStrategy = pgSeoTable( description: text(), motivation: text().notNull(), goal: jsonb().$type().notNull(), + dismissalReason: text(), status: text({ enum: STRATEGY_PHASE_STATUSES }) .notNull() .default("suggestion"), From 63e8aa0273bb4a4429ac8d2ae8c391f2fef16ffa Mon Sep 17 00:00:00 2001 From: ElasticBottle Date: Sun, 1 Feb 2026 15:41:15 +0800 Subject: [PATCH 16/60] fix(seo): expose the strategy suggestion workflow --- apps/seo/src/server.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/seo/src/server.ts b/apps/seo/src/server.ts index 092b245a..aa92e647 100644 --- a/apps/seo/src/server.ts +++ b/apps/seo/src/server.ts @@ -23,6 +23,7 @@ export { WebSocketServer } from "@rectangular-labs/api-seo/websocket-server"; export { SeoOnboardingWorkflow, SeoPlannerWorkflow, + SeoStrategySuggestionsWorkflow, SeoWriterWorkflow, } from "@rectangular-labs/api-seo/workflows"; export { UserVMContainer } from "@rectangular-labs/api-user-vm/container"; From b78a2d7a97ae98844529578f191df5526fbb2abc Mon Sep 17 00:00:00 2001 From: ElasticBottle Date: Sun, 1 Feb 2026 16:13:11 +0800 Subject: [PATCH 17/60] feat(seo): add strategy suggestion card --- .../-components/strategy-modify-dialog.tsx | 314 ++++++++++++++++++ .../-components/strategy-suggestion-card.tsx | 249 ++++++++++++++ 2 files changed, 563 insertions(+) create mode 100644 apps/seo/src/routes/_authed/$organizationSlug/$projectSlug/-components/strategy-modify-dialog.tsx create mode 100644 apps/seo/src/routes/_authed/$organizationSlug/$projectSlug/-components/strategy-suggestion-card.tsx diff --git a/apps/seo/src/routes/_authed/$organizationSlug/$projectSlug/-components/strategy-modify-dialog.tsx b/apps/seo/src/routes/_authed/$organizationSlug/$projectSlug/-components/strategy-modify-dialog.tsx new file mode 100644 index 00000000..1cef086a --- /dev/null +++ b/apps/seo/src/routes/_authed/$organizationSlug/$projectSlug/-components/strategy-modify-dialog.tsx @@ -0,0 +1,314 @@ +"use client"; + +import type { RouterOutputs } from "@rectangular-labs/api-seo/types"; +import { strategySuggestionSchema } from "@rectangular-labs/core/schemas/strategy-parsers"; +import { Button } from "@rectangular-labs/ui/components/ui/button"; +import { + DialogDrawer, + DialogDrawerFooter, + DialogDrawerHeader, + DialogDrawerTitle, +} from "@rectangular-labs/ui/components/ui/dialog-drawer"; +import { + arktypeResolver, + Controller, + Field, + FieldError, + FieldGroup, + FieldLabel, + useForm, +} from "@rectangular-labs/ui/components/ui/field"; +import { Input } from "@rectangular-labs/ui/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@rectangular-labs/ui/components/ui/select"; +import { toast } from "@rectangular-labs/ui/components/ui/sonner"; +import { Textarea } from "@rectangular-labs/ui/components/ui/textarea"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useMemo, useState } from "react"; +import { getApiClientRq } from "~/lib/api"; + +type StrategySummary = RouterOutputs["strategy"]["list"]["strategies"][number]; + +const formSchema = strategySuggestionSchema; +type StrategyFormValues = typeof formSchema.infer; + +const goalMetricOptions = [ + { value: "conversions", label: "Conversions" }, + { value: "clicks", label: "Clicks" }, + { value: "impressions", label: "Impressions" }, + { value: "avgPosition", label: "Average position" }, +] as const; + +const goalTimeframeOptions = [ + { value: "monthly", label: "Monthly" }, + { value: "total", label: "Total" }, +] as const; + +export function StrategyModifyDialog({ + strategy, + organizationId, + projectId, +}: { + strategy: StrategySummary; + organizationId: string; + projectId: string; +}) { + const [open, setOpen] = useState(false); + const api = getApiClientRq(); + const queryClient = useQueryClient(); + + const defaultValues = useMemo( + () => ({ + name: strategy.name ?? "", + description: strategy.description ?? "", + motivation: strategy.motivation ?? "", + goal: { + metric: strategy.goal.metric, + target: strategy.goal.target, + timeframe: strategy.goal.timeframe, + }, + }), + [strategy], + ); + + const form = useForm({ + resolver: arktypeResolver(formSchema), + defaultValues, + }); + + useEffect(() => { + if (open) { + form.reset(defaultValues); + } + }, [defaultValues, form, open]); + + const { mutate: updateStrategy, isPending } = useMutation( + api.strategy.update.mutationOptions({ + onError: (error) => { + form.setError("root", { message: error.message }); + }, + onSuccess: async () => { + toast.success("Strategy updated"); + setOpen(false); + if (!organizationId || !projectId) return; + await queryClient.invalidateQueries({ + queryKey: api.strategy.list.queryKey({ + input: { organizationIdentifier: organizationId, projectId }, + }), + }); + }, + }), + ); + + const submitForm = (values: StrategyFormValues) => { + if (!organizationId || !projectId) { + form.setError("root", { + message: "Missing organization or project details.", + }); + return; + } + + updateStrategy({ + id: strategy.id, + projectId, + organizationIdentifier: organizationId, + name: values.name.trim(), + motivation: values.motivation.trim(), + description: values.description?.trim() || null, + goal: { + metric: values.goal.metric, + target: values.goal.target, + timeframe: values.goal.timeframe, + }, + }); + }; + + const fieldPrefix = `strategy-modify-${strategy.id}`; + const formError = form.formState.errors.root?.message; + + return ( + + Modify + + } + > + + Edit strategy + + +
+ + ( + + Name + + {fieldState.invalid && ( + + )} + + )} + /> + + ( + + + Description + +