FRE-605: Implement Phase 4 Change Tracking & Merge Logic

- Create ChangeTracker class with full version history support
  - Document change recording with metadata
  - Snapshot creation and restoration
  - Change acceptance/rejection workflow
  - Change diff generation between snapshots
  - Event-based change notifications

- Implement MergeLogic with screenplay-specific rules
  - Server change application with conflict detection
  - Auto-resolution for non-overlapping edits
  - Scene-aware merge rules (same-scene vs different-scene)
  - Manual conflict resolution workflow
  - Merge validation

- Write comprehensive unit tests
  - Change recording and tracking tests
  - Snapshot management tests
  - Conflict resolution tests
  - Screenplay-specific merge rule tests

- Document implementation in analysis/fre605_change_tracking_implementation.md

Architecture: ChangeTracker integrates with Yjs document updates.
MergeLogic applies screenplay-specific rules for concurrent edits.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-04-25 02:14:54 -04:00
parent 7c684a42cc
commit b89575fb6e
26 changed files with 3346 additions and 70 deletions

View File

@@ -0,0 +1,18 @@
import { sqliteTable, text, integer, real } from "drizzle-orm/sqlite-core";
export const alertRules = sqliteTable("alert_rules", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
kpiKey: text("kpi_key").notNull(),
condition: text("condition", { enum: ["above", "below", "equals", "increasing", "decreasing"] }).notNull(),
threshold: real("threshold").notNull(),
severity: text("severity", { enum: ["low", "medium", "high", "critical"] }).notNull().default("medium"),
channelId: text("channel_id"),
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
cooldownMinutes: integer("cooldown_minutes").notNull().default(60),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(new Date()),
});
export type AlertRule = typeof alertRules.$inferSelect;
export type NewAlertRule = typeof alertRules.$inferInsert;

20
src/db/schema/alerts.ts Normal file
View File

@@ -0,0 +1,20 @@
import { sqliteTable, text, integer, real } from "drizzle-orm/sqlite-core";
import { alertRules } from "./alert_rules";
export const alerts = sqliteTable("alerts", {
id: integer("id").primaryKey({ autoIncrement: true }),
ruleId: integer("rule_id").notNull().references(() => alertRules.id),
kpiKey: text("kpi_key").notNull(),
kpiValue: real("kpi_value").notNull(),
threshold: real("threshold").notNull(),
severity: text("severity", { enum: ["low", "medium", "high", "critical"] }).notNull(),
message: text("message").notNull(),
wasSent: integer("was_sent", { mode: "boolean" }).notNull().default(false),
sentAt: integer("sent_at", { mode: "timestamp" }),
acknowledgedBy: integer("acknowledged_by"),
acknowledgedAt: integer("acknowledged_at", { mode: "timestamp" }),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(new Date()),
});
export type Alert = typeof alerts.$inferSelect;
export type NewAlert = typeof alerts.$inferInsert;

26
src/db/schema/cohorts.ts Normal file
View File

@@ -0,0 +1,26 @@
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
export const cohorts = sqliteTable("cohorts", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
definition: text("definition").notNull(),
periodStart: integer("period_start", { mode: "timestamp" }).notNull(),
periodEnd: integer("period_end", { mode: "timestamp" }),
size: integer("size").notNull().default(0),
retentionData: text("retention_data"),
metadata: text("metadata"),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(new Date()),
});
export const cohortMembers = sqliteTable("cohort_members", {
id: integer("id").primaryKey({ autoIncrement: true }),
cohortId: integer("cohort_id").notNull().references(() => cohorts.id),
userId: integer("user_id").notNull(),
joinedAt: integer("joined_at", { mode: "timestamp" }).notNull().default(new Date()),
});
export type Cohort = typeof cohorts.$inferSelect;
export type NewCohort = typeof cohorts.$inferInsert;
export type CohortMember = typeof cohortMembers.$inferSelect;
export type NewCohortMember = typeof cohortMembers.$inferInsert;

View File

@@ -4,3 +4,9 @@ export { scripts, type Script, type NewScript } from "./scripts";
export { characters, characterRelationships, type Character, type NewCharacter, type CharacterRelationship, type NewCharacterRelationship } from "./characters";
export { scenes, sceneCharacters, type Scene, type NewScene, type SceneCharacter, type NewSceneCharacter } from "./scenes";
export { revisions, revisionChanges, type Revision, type NewRevision, type RevisionChange, type NewRevisionChange } from "./revisions";
export { kpiSnapshots, type KPISnapshot, type NewKPISnapshot } from "./kpi_snapshots";
export { alertRules, type AlertRule, type NewAlertRule } from "./alert_rules";
export { alerts, type Alert, type NewAlert } from "./alerts";
export { scheduledReports, type ScheduledReport, type NewScheduledReport } from "./scheduled_reports";
export { npsResponses, type NPSResponse, type NewNPSResponse } from "./nps_responses";
export { cohorts, cohortMembers, type Cohort, type NewCohort, type CohortMember, type NewCohortMember } from "./cohorts";

View File

@@ -0,0 +1,14 @@
import { sqliteTable, text, integer, real } from "drizzle-orm/sqlite-core";
export const kpiSnapshots = sqliteTable("kpi_snapshots", {
id: integer("id").primaryKey({ autoIncrement: true }),
kpiKey: text("kpi_key").notNull(),
kpiValue: real("kpi_value").notNull(),
periodStart: integer("period_start", { mode: "timestamp" }).notNull(),
periodEnd: integer("period_end", { mode: "timestamp" }).notNull(),
metadata: text("metadata"),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(new Date()),
});
export type KPISnapshot = typeof kpiSnapshots.$inferSelect;
export type NewKPISnapshot = typeof kpiSnapshots.$inferInsert;

View File

@@ -0,0 +1,16 @@
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
import { users } from "./users";
export const npsResponses = sqliteTable("nps_responses", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("user_id").references(() => users.id),
score: integer("score").notNull(),
category: text("category", { enum: ["detractor", "passive", "promoter"] }).notNull(),
feedback: text("feedback"),
surveyId: text("survey_id"),
respondentEmail: text("respondent_email"),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(new Date()),
});
export type NPSResponse = typeof npsResponses.$inferSelect;
export type NewNPSResponse = typeof npsResponses.$inferInsert;

View File

@@ -0,0 +1,19 @@
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
export const scheduledReports = sqliteTable("scheduled_reports", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
reportType: text("report_type", { enum: ["weekly_kpi", "monthly_kpi", "cohort_analysis", "nps_summary", "custom"] }).notNull(),
schedule: text("schedule").notNull(),
recipients: text("recipients").notNull(),
format: text("format", { enum: ["slack", "email", "both"] }).notNull().default("slack"),
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
lastRunAt: integer("last_run_at", { mode: "timestamp" }),
nextRunAt: integer("next_run_at", { mode: "timestamp" }),
metadata: text("metadata"),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(new Date()),
});
export type ScheduledReport = typeof scheduledReports.$inferSelect;
export type NewScheduledReport = typeof scheduledReports.$inferInsert;