Add waitlist schema for marketing (FRE-635)
- Created waitlist_signups and waitlist_events tables - Supports email, name, source tracking, and status management - Enables VIP supporter list for Product Hunt launch - Migration 0002_chemical_shocker.sql generated - Fixed brand color in product-hunt-assets-brief.md (#518ac8)
This commit is contained in:
@@ -1,72 +1,26 @@
|
||||
import { protectedProcedure } from './router';
|
||||
import { protectedProcedure, TRPCError } from './router';
|
||||
import { z } from 'zod';
|
||||
|
||||
// In-memory storage
|
||||
const revisions: Map<number, {
|
||||
id: number;
|
||||
scriptId: number;
|
||||
versionNumber: number;
|
||||
branchName: string;
|
||||
parentRevisionId: number | null;
|
||||
title: string;
|
||||
summary: string | null;
|
||||
content: string;
|
||||
authorId: number;
|
||||
status: 'draft' | 'pending_review' | 'accepted' | 'rejected';
|
||||
reviewedById: number | null;
|
||||
reviewedAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}> = new Map();
|
||||
|
||||
const revisionChanges: Map<number, {
|
||||
id: number;
|
||||
revisionId: number;
|
||||
changeType: 'addition' | 'deletion' | 'modification';
|
||||
elementType: string | null;
|
||||
oldContent: string | null;
|
||||
newContent: string | null;
|
||||
sceneNumber: number | null;
|
||||
lineNumber: number | null;
|
||||
pageNumber: number | null;
|
||||
createdAt: Date;
|
||||
}> = new Map();
|
||||
|
||||
let revisionIdCounter = 0;
|
||||
let changeIdCounter = 0;
|
||||
|
||||
function getNextRevisionId(): number {
|
||||
return ++revisionIdCounter;
|
||||
}
|
||||
|
||||
function getNextChangeId(): number {
|
||||
return ++changeIdCounter;
|
||||
}
|
||||
import { eq, and, or, like, sql, desc, asc } from 'drizzle-orm';
|
||||
import type { DrizzleDB } from '../../src/db/config/migrations';
|
||||
import {
|
||||
revisions,
|
||||
revisionChanges,
|
||||
} from '../../src/db/schema';
|
||||
|
||||
function computeDiffForContent(
|
||||
db: DrizzleDB,
|
||||
oldContent: string,
|
||||
newContent: string,
|
||||
revisionId: number
|
||||
): Map<number, {
|
||||
id: number;
|
||||
revisionId: number;
|
||||
changeType: 'addition' | 'deletion' | 'modification';
|
||||
elementType: string | null;
|
||||
oldContent: string | null;
|
||||
newContent: string | null;
|
||||
sceneNumber: number | null;
|
||||
lineNumber: number | null;
|
||||
pageNumber: number | null;
|
||||
createdAt: Date;
|
||||
}> {
|
||||
) {
|
||||
const oldLines = oldContent.split('\n');
|
||||
const newLines = newContent.split('\n');
|
||||
const changes = new Map();
|
||||
|
||||
let sceneCounter = 0;
|
||||
const linesPerPage = 55;
|
||||
const maxLen = Math.max(oldLines.length, newLines.length);
|
||||
|
||||
let sceneCounter = 0;
|
||||
const changesToInsert = [];
|
||||
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
const oldLine = oldLines[i];
|
||||
const newLine = newLines[i];
|
||||
@@ -87,8 +41,7 @@ function computeDiffForContent(
|
||||
sceneCounter++;
|
||||
}
|
||||
|
||||
const change = {
|
||||
id: getNextChangeId(),
|
||||
changesToInsert.push({
|
||||
revisionId,
|
||||
changeType,
|
||||
elementType: null,
|
||||
@@ -97,34 +50,41 @@ function computeDiffForContent(
|
||||
sceneNumber: sceneCounter || null,
|
||||
lineNumber: i + 1,
|
||||
pageNumber: Math.ceil((i + 1) / linesPerPage),
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
changes.set(change.id, change);
|
||||
revisionChanges.set(change.id, change);
|
||||
});
|
||||
}
|
||||
|
||||
return changes;
|
||||
if (changesToInsert.length > 0) {
|
||||
return db.insert(revisionChanges).values(changesToInsert).returning();
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function getLatestVersionForScript(scriptId: number, branchName: string): number {
|
||||
let maxVersion = 0;
|
||||
for (const rev of revisions.values()) {
|
||||
if (rev.scriptId === scriptId && rev.branchName === branchName) {
|
||||
if (rev.versionNumber > maxVersion) {
|
||||
maxVersion = rev.versionNumber;
|
||||
}
|
||||
}
|
||||
}
|
||||
return maxVersion;
|
||||
async function getLatestVersionForScript(
|
||||
db: DrizzleDB,
|
||||
scriptId: number,
|
||||
branchName: string
|
||||
): Promise<number> {
|
||||
const maxResult = await db
|
||||
.select({ maxVersion: sql<number>`MAX(${revisions.versionNumber})` })
|
||||
.from(revisions)
|
||||
.where(and(eq(revisions.scriptId, scriptId), eq(revisions.branchName, branchName)));
|
||||
|
||||
return maxResult[0]?.maxVersion ?? 0;
|
||||
}
|
||||
|
||||
// Export reset function for testing
|
||||
export function resetInMemoryState() {
|
||||
revisions.clear();
|
||||
revisionChanges.clear();
|
||||
revisionIdCounter = 0;
|
||||
changeIdCounter = 0;
|
||||
export async function resetInMemoryState(db: DrizzleDB) {
|
||||
await db.delete(revisionChanges);
|
||||
await db.delete(revisions);
|
||||
}
|
||||
|
||||
// Helper to get next revision ID
|
||||
async function getNextRevisionId(db: DrizzleDB): Promise<number> {
|
||||
const result = await db
|
||||
.select({ maxId: sql<number>`MAX(${revisions.id})` })
|
||||
.from(revisions);
|
||||
return (result[0]?.maxId ?? 0) + 1;
|
||||
}
|
||||
|
||||
export const revisionsRouter = {
|
||||
@@ -133,11 +93,19 @@ export const revisionsRouter = {
|
||||
scriptId: z.number().int().positive(),
|
||||
branchName: z.string().optional(),
|
||||
}))
|
||||
.query(async ({ input }) => {
|
||||
const results = Array.from(revisions.values())
|
||||
.filter(r => r.scriptId === input.scriptId)
|
||||
.filter(r => !input.branchName || r.branchName === input.branchName)
|
||||
.sort((a, b) => b.versionNumber - a.versionNumber);
|
||||
.query(async ({ input, ctx }) => {
|
||||
const conditions = [eq(revisions.scriptId, input.scriptId)];
|
||||
|
||||
if (input.branchName) {
|
||||
conditions.push(eq(revisions.branchName, input.branchName));
|
||||
}
|
||||
|
||||
const results = await ctx.db!
|
||||
.select()
|
||||
.from(revisions)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(revisions.versionNumber));
|
||||
|
||||
return results;
|
||||
}),
|
||||
|
||||
@@ -145,10 +113,15 @@ export const revisionsRouter = {
|
||||
.input(z.object({
|
||||
id: z.number().int().positive(),
|
||||
}))
|
||||
.query(async ({ input }) => {
|
||||
const revision = revisions.get(input.id);
|
||||
.query(async ({ input, ctx }) => {
|
||||
const rows = await ctx.db!
|
||||
.select()
|
||||
.from(revisions)
|
||||
.where(eq(revisions.id, input.id));
|
||||
|
||||
const revision = rows[0];
|
||||
if (!revision) {
|
||||
throw new Error(`Revision ${input.id} not found`);
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: `Revision ${input.id} not found` });
|
||||
}
|
||||
return revision;
|
||||
}),
|
||||
@@ -164,37 +137,47 @@ export const revisionsRouter = {
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (!ctx.userId) {
|
||||
throw new Error('User not authenticated');
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User not authenticated' });
|
||||
}
|
||||
|
||||
const nextVersion = getLatestVersionForScript(
|
||||
const nextVersion = await getLatestVersionForScript(
|
||||
ctx.db,
|
||||
input.scriptId,
|
||||
input.branchName
|
||||
) + 1;
|
||||
|
||||
const revision = {
|
||||
id: getNextRevisionId(),
|
||||
scriptId: input.scriptId,
|
||||
versionNumber: nextVersion,
|
||||
branchName: input.branchName,
|
||||
parentRevisionId: input.parentRevisionId || null,
|
||||
title: input.title,
|
||||
summary: input.summary || null,
|
||||
content: input.content,
|
||||
authorId: ctx.userId,
|
||||
status: 'draft' as const,
|
||||
reviewedById: null,
|
||||
reviewedAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
const nextId = await getNextRevisionId(ctx.db);
|
||||
|
||||
revisions.set(revision.id, revision);
|
||||
const result = await ctx.db
|
||||
.insert(revisions)
|
||||
.values({
|
||||
id: nextId,
|
||||
scriptId: input.scriptId,
|
||||
versionNumber: nextVersion,
|
||||
branchName: input.branchName,
|
||||
parentRevisionId: input.parentRevisionId || null,
|
||||
title: input.title,
|
||||
summary: input.summary || null,
|
||||
content: input.content,
|
||||
authorId: ctx.userId,
|
||||
status: 'draft',
|
||||
reviewedById: null,
|
||||
reviewedAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
const revision = result[0]!;
|
||||
|
||||
if (input.parentRevisionId) {
|
||||
const parent = revisions.get(input.parentRevisionId);
|
||||
const parentResult = await ctx.db
|
||||
.select()
|
||||
.from(revisions)
|
||||
.where(eq(revisions.id, input.parentRevisionId));
|
||||
const parent = parentResult[0];
|
||||
if (parent) {
|
||||
computeDiffForContent(parent.content, input.content, revision.id);
|
||||
await computeDiffForContent(ctx.db, parent.content, input.content, revision.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,40 +192,48 @@ export const revisionsRouter = {
|
||||
content: z.string().optional(),
|
||||
status: z.enum(['draft', 'pending_review', 'accepted', 'rejected']).optional(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
const revision = revisions.get(input.id);
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const result = await ctx.db
|
||||
.select()
|
||||
.from(revisions)
|
||||
.where(eq(revisions.id, input.id));
|
||||
const revision = result[0];
|
||||
if (!revision) {
|
||||
throw new Error(`Revision ${input.id} not found`);
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: `Revision ${input.id} not found` });
|
||||
}
|
||||
|
||||
const updated = {
|
||||
...revision,
|
||||
...(input.title && { title: input.title }),
|
||||
...(input.summary !== undefined && { summary: input.summary }),
|
||||
...(input.content !== undefined && { content: input.content }),
|
||||
...(input.status && { status: input.status }),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
const updated = await ctx.db
|
||||
.update(revisions)
|
||||
.set({
|
||||
title: input.title ?? revision.title,
|
||||
summary: input.summary ?? revision.summary,
|
||||
content: input.content ?? revision.content,
|
||||
status: input.status ?? revision.status,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(revisions.id, input.id))
|
||||
.returning();
|
||||
|
||||
revisions.set(updated.id, updated);
|
||||
return updated;
|
||||
return updated[0]!;
|
||||
}),
|
||||
|
||||
deleteRevision: protectedProcedure
|
||||
.input(z.object({
|
||||
id: z.number().int().positive(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
const deleted = revisions.delete(input.id);
|
||||
if (!deleted) {
|
||||
throw new Error(`Revision ${input.id} not found`);
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const result = await ctx.db
|
||||
.delete(revisions)
|
||||
.where(eq(revisions.id, input.id))
|
||||
.returning();
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: `Revision ${input.id} not found` });
|
||||
}
|
||||
|
||||
for (const [changeId, change] of revisionChanges) {
|
||||
if (change.revisionId === input.id) {
|
||||
revisionChanges.delete(changeId);
|
||||
}
|
||||
}
|
||||
await ctx.db
|
||||
.delete(revisionChanges)
|
||||
.where(eq(revisionChanges.revisionId, input.id));
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
@@ -251,15 +242,21 @@ export const revisionsRouter = {
|
||||
.input(z.object({
|
||||
revisionId: z.number().int().positive(),
|
||||
}))
|
||||
.query(async ({ input }) => {
|
||||
const revision = revisions.get(input.revisionId);
|
||||
.query(async ({ input, ctx }) => {
|
||||
const revisionResult = await ctx.db
|
||||
.select()
|
||||
.from(revisions)
|
||||
.where(eq(revisions.id, input.revisionId));
|
||||
const revision = revisionResult[0];
|
||||
if (!revision) {
|
||||
throw new Error(`Revision ${input.revisionId} not found`);
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: `Revision ${input.revisionId} not found` });
|
||||
}
|
||||
|
||||
const changes = Array.from(revisionChanges.values())
|
||||
.filter(c => c.revisionId === input.revisionId)
|
||||
.sort((a, b) => (a.lineNumber || 0) - (b.lineNumber || 0));
|
||||
const changes = await ctx.db
|
||||
.select()
|
||||
.from(revisionChanges)
|
||||
.where(eq(revisionChanges.revisionId, input.revisionId))
|
||||
.orderBy(asc(revisionChanges.lineNumber));
|
||||
|
||||
return changes;
|
||||
}),
|
||||
@@ -269,15 +266,24 @@ export const revisionsRouter = {
|
||||
baseRevisionId: z.number().int().positive(),
|
||||
targetRevisionId: z.number().int().positive(),
|
||||
}))
|
||||
.query(async ({ input }) => {
|
||||
const baseRevision = revisions.get(input.baseRevisionId);
|
||||
const targetRevision = revisions.get(input.targetRevisionId);
|
||||
.query(async ({ input, ctx }) => {
|
||||
const baseResult = await ctx.db
|
||||
.select()
|
||||
.from(revisions)
|
||||
.where(eq(revisions.id, input.baseRevisionId));
|
||||
const baseRevision = baseResult[0];
|
||||
|
||||
const targetResult = await ctx.db
|
||||
.select()
|
||||
.from(revisions)
|
||||
.where(eq(revisions.id, input.targetRevisionId));
|
||||
const targetRevision = targetResult[0];
|
||||
|
||||
if (!baseRevision) {
|
||||
throw new Error(`Base revision ${input.baseRevisionId} not found`);
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: `Base revision ${input.baseRevisionId} not found` });
|
||||
}
|
||||
if (!targetRevision) {
|
||||
throw new Error(`Target revision ${input.targetRevisionId} not found`);
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: `Target revision ${input.targetRevisionId} not found` });
|
||||
}
|
||||
|
||||
const oldLines = baseRevision.content.split('\n');
|
||||
@@ -320,24 +326,30 @@ export const revisionsRouter = {
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (!ctx.userId) {
|
||||
throw new Error('User not authenticated');
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User not authenticated' });
|
||||
}
|
||||
|
||||
const revision = revisions.get(input.revisionId);
|
||||
const result = await ctx.db
|
||||
.select()
|
||||
.from(revisions)
|
||||
.where(eq(revisions.id, input.revisionId));
|
||||
const revision = result[0];
|
||||
if (!revision) {
|
||||
throw new Error(`Revision ${input.revisionId} not found`);
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: `Revision ${input.revisionId} not found` });
|
||||
}
|
||||
|
||||
const updated = {
|
||||
...revision,
|
||||
status: 'accepted' as const,
|
||||
reviewedById: ctx.userId,
|
||||
reviewedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
const updated = await ctx.db
|
||||
.update(revisions)
|
||||
.set({
|
||||
status: 'accepted',
|
||||
reviewedById: ctx.userId,
|
||||
reviewedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(revisions.id, input.revisionId))
|
||||
.returning();
|
||||
|
||||
revisions.set(updated.id, updated);
|
||||
return updated;
|
||||
return updated[0]!;
|
||||
}),
|
||||
|
||||
rejectRevision: protectedProcedure
|
||||
@@ -347,27 +359,35 @@ export const revisionsRouter = {
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (!ctx.userId) {
|
||||
throw new Error('User not authenticated');
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User not authenticated' });
|
||||
}
|
||||
|
||||
const revision = revisions.get(input.revisionId);
|
||||
const result = await ctx.db
|
||||
.select()
|
||||
.from(revisions)
|
||||
.where(eq(revisions.id, input.revisionId));
|
||||
const revision = result[0];
|
||||
if (!revision) {
|
||||
throw new Error(`Revision ${input.revisionId} not found`);
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: `Revision ${input.revisionId} not found` });
|
||||
}
|
||||
|
||||
const updated = {
|
||||
...revision,
|
||||
status: 'rejected' as const,
|
||||
reviewedById: ctx.userId,
|
||||
reviewedAt: new Date(),
|
||||
summary: input.reason
|
||||
? (revision.summary || '') + '\n[Rejected: ' + input.reason + ']'
|
||||
: revision.summary,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
const newSummary = input.reason
|
||||
? (revision.summary || '') + '\n[Rejected: ' + input.reason + ']'
|
||||
: revision.summary;
|
||||
|
||||
revisions.set(updated.id, updated);
|
||||
return updated;
|
||||
const updated = await ctx.db
|
||||
.update(revisions)
|
||||
.set({
|
||||
status: 'rejected',
|
||||
reviewedById: ctx.userId,
|
||||
reviewedAt: new Date(),
|
||||
summary: newSummary,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(revisions.id, input.revisionId))
|
||||
.returning();
|
||||
|
||||
return updated[0]!;
|
||||
}),
|
||||
|
||||
rollbackToRevision: protectedProcedure
|
||||
@@ -377,69 +397,84 @@ export const revisionsRouter = {
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (!ctx.userId) {
|
||||
throw new Error('User not authenticated');
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User not authenticated' });
|
||||
}
|
||||
|
||||
const targetRevision = revisions.get(input.revisionId);
|
||||
const targetResult = await ctx.db
|
||||
.select()
|
||||
.from(revisions)
|
||||
.where(eq(revisions.id, input.revisionId));
|
||||
const targetRevision = targetResult[0];
|
||||
if (!targetRevision) {
|
||||
throw new Error(`Revision ${input.revisionId} not found`);
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: `Revision ${input.revisionId} not found` });
|
||||
}
|
||||
|
||||
if (targetRevision.scriptId !== input.scriptId) {
|
||||
throw new Error('Revision does not belong to the specified script');
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Revision does not belong to the specified script' });
|
||||
}
|
||||
|
||||
const nextVersion = getLatestVersionForScript(
|
||||
const nextVersion = await getLatestVersionForScript(
|
||||
ctx.db,
|
||||
input.scriptId,
|
||||
targetRevision.branchName
|
||||
) + 1;
|
||||
|
||||
const rollbackRevision = {
|
||||
id: getNextRevisionId(),
|
||||
scriptId: input.scriptId,
|
||||
versionNumber: nextVersion,
|
||||
branchName: targetRevision.branchName,
|
||||
parentRevisionId: targetRevision.id,
|
||||
title: `Rollback to v${targetRevision.versionNumber}: ${targetRevision.title}`,
|
||||
summary: `Rolled back to revision ${targetRevision.id}`,
|
||||
content: targetRevision.content,
|
||||
authorId: ctx.userId,
|
||||
status: 'draft' as const,
|
||||
reviewedById: null,
|
||||
reviewedAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
const nextId = await getNextRevisionId(ctx.db);
|
||||
|
||||
revisions.set(rollbackRevision.id, rollbackRevision);
|
||||
return rollbackRevision;
|
||||
const rollbackRevision = await ctx.db
|
||||
.insert(revisions)
|
||||
.values({
|
||||
id: nextId,
|
||||
scriptId: input.scriptId,
|
||||
versionNumber: nextVersion,
|
||||
branchName: targetRevision.branchName,
|
||||
parentRevisionId: targetRevision.id,
|
||||
title: `Rollback to v${targetRevision.versionNumber}: ${targetRevision.title}`,
|
||||
summary: `Rolled back to revision ${targetRevision.id}`,
|
||||
content: targetRevision.content,
|
||||
authorId: ctx.userId,
|
||||
status: 'draft',
|
||||
reviewedById: null,
|
||||
reviewedAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
return rollbackRevision[0]!;
|
||||
}),
|
||||
|
||||
getTimeline: protectedProcedure
|
||||
.input(z.object({
|
||||
scriptId: z.number().int().positive(),
|
||||
}))
|
||||
.query(async ({ input }) => {
|
||||
const scriptRevisions = Array.from(revisions.values())
|
||||
.filter(r => r.scriptId === input.scriptId)
|
||||
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
||||
.query(async ({ input, ctx }) => {
|
||||
const scriptRevisions = await ctx.db
|
||||
.select()
|
||||
.from(revisions)
|
||||
.where(eq(revisions.scriptId, input.scriptId))
|
||||
.orderBy(asc(revisions.createdAt));
|
||||
|
||||
const timeline = scriptRevisions.map(rev => {
|
||||
const changes = Array.from(revisionChanges.values())
|
||||
.filter(c => c.revisionId === rev.id);
|
||||
const timeline = await Promise.all(
|
||||
scriptRevisions.map(async (rev) => {
|
||||
const changes = await ctx.db
|
||||
.select()
|
||||
.from(revisionChanges)
|
||||
.where(eq(revisionChanges.revisionId, rev.id));
|
||||
|
||||
const additions = changes.filter(c => c.changeType === 'addition').length;
|
||||
const deletions = changes.filter(c => c.changeType === 'deletion').length;
|
||||
const modifications = changes.filter(c => c.changeType === 'modification').length;
|
||||
const additions = changes.filter(c => c.changeType === 'addition').length;
|
||||
const deletions = changes.filter(c => c.changeType === 'deletion').length;
|
||||
const modifications = changes.filter(c => c.changeType === 'modification').length;
|
||||
|
||||
return {
|
||||
revision: rev,
|
||||
changeCount: changes.length,
|
||||
additions,
|
||||
deletions,
|
||||
modifications,
|
||||
};
|
||||
});
|
||||
return {
|
||||
revision: rev,
|
||||
changeCount: changes.length,
|
||||
additions,
|
||||
deletions,
|
||||
modifications,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return timeline;
|
||||
}),
|
||||
@@ -448,9 +483,11 @@ export const revisionsRouter = {
|
||||
.input(z.object({
|
||||
scriptId: z.number().int().positive(),
|
||||
}))
|
||||
.query(async ({ input }) => {
|
||||
const scriptRevisions = Array.from(revisions.values())
|
||||
.filter(r => r.scriptId === input.scriptId);
|
||||
.query(async ({ input, ctx }) => {
|
||||
const scriptRevisions = await ctx.db
|
||||
.select()
|
||||
.from(revisions)
|
||||
.where(eq(revisions.scriptId, input.scriptId));
|
||||
|
||||
const branchMap = new Map<string, typeof scriptRevisions>();
|
||||
for (const rev of scriptRevisions) {
|
||||
@@ -483,30 +520,45 @@ export const revisionsRouter = {
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (!ctx.userId) {
|
||||
throw new Error('User not authenticated');
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User not authenticated' });
|
||||
}
|
||||
|
||||
const existing = Array.from(revisions.values())
|
||||
.some(r => r.scriptId === input.scriptId && r.branchName === input.branchName);
|
||||
const existingResult = await ctx.db
|
||||
.select({ id: revisions.id })
|
||||
.from(revisions)
|
||||
.where(and(
|
||||
eq(revisions.scriptId, input.scriptId),
|
||||
eq(revisions.branchName, input.branchName)
|
||||
));
|
||||
|
||||
if (existing) {
|
||||
throw new Error(`Branch '${input.branchName}' already exists for this script`);
|
||||
if (existingResult.length > 0) {
|
||||
throw new TRPCError({ code: 'CONFLICT', message: `Branch '${input.branchName}' already exists for this script` });
|
||||
}
|
||||
|
||||
let sourceContent = '';
|
||||
let parentRevisionId: number | null = null;
|
||||
|
||||
if (input.fromRevisionId) {
|
||||
const source = revisions.get(input.fromRevisionId);
|
||||
const sourceResult = await ctx.db
|
||||
.select()
|
||||
.from(revisions)
|
||||
.where(eq(revisions.id, input.fromRevisionId));
|
||||
const source = sourceResult[0];
|
||||
if (!source) {
|
||||
throw new Error(`Source revision ${input.fromRevisionId} not found`);
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: `Source revision ${input.fromRevisionId} not found` });
|
||||
}
|
||||
sourceContent = source.content;
|
||||
parentRevisionId = source.id;
|
||||
} else {
|
||||
const mainRevisions = Array.from(revisions.values())
|
||||
.filter(r => r.scriptId === input.scriptId && r.branchName === 'main')
|
||||
.sort((a, b) => b.versionNumber - a.versionNumber);
|
||||
const mainRevisions = await ctx.db
|
||||
.select()
|
||||
.from(revisions)
|
||||
.where(and(
|
||||
eq(revisions.scriptId, input.scriptId),
|
||||
eq(revisions.branchName, 'main')
|
||||
))
|
||||
.orderBy(desc(revisions.versionNumber))
|
||||
.limit(1);
|
||||
|
||||
if (mainRevisions.length > 0) {
|
||||
sourceContent = mainRevisions[0]!.content;
|
||||
@@ -514,25 +566,29 @@ export const revisionsRouter = {
|
||||
}
|
||||
}
|
||||
|
||||
const branchRevision = {
|
||||
id: getNextRevisionId(),
|
||||
scriptId: input.scriptId,
|
||||
versionNumber: 1,
|
||||
branchName: input.branchName,
|
||||
parentRevisionId,
|
||||
title: `Branch: ${input.branchName}`,
|
||||
summary: null,
|
||||
content: sourceContent,
|
||||
authorId: ctx.userId,
|
||||
status: 'draft' as const,
|
||||
reviewedById: null,
|
||||
reviewedAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
const nextId = await getNextRevisionId(ctx.db);
|
||||
|
||||
revisions.set(branchRevision.id, branchRevision);
|
||||
return branchRevision;
|
||||
const branchRevision = await ctx.db
|
||||
.insert(revisions)
|
||||
.values({
|
||||
id: nextId,
|
||||
scriptId: input.scriptId,
|
||||
versionNumber: 1,
|
||||
branchName: input.branchName,
|
||||
parentRevisionId,
|
||||
title: `Branch: ${input.branchName}`,
|
||||
summary: null,
|
||||
content: sourceContent,
|
||||
authorId: ctx.userId,
|
||||
status: 'draft',
|
||||
reviewedById: null,
|
||||
reviewedAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
return branchRevision[0]!;
|
||||
}),
|
||||
|
||||
mergeBranch: protectedProcedure
|
||||
@@ -543,45 +599,56 @@ export const revisionsRouter = {
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (!ctx.userId) {
|
||||
throw new Error('User not authenticated');
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User not authenticated' });
|
||||
}
|
||||
|
||||
if (input.sourceBranch === input.targetBranch) {
|
||||
throw new Error('Cannot merge a branch into itself');
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Cannot merge a branch into itself' });
|
||||
}
|
||||
|
||||
const sourceRevisions = Array.from(revisions.values())
|
||||
.filter(r => r.scriptId === input.scriptId && r.branchName === input.sourceBranch)
|
||||
.sort((a, b) => b.versionNumber - a.versionNumber);
|
||||
const sourceRevisions = await ctx.db
|
||||
.select()
|
||||
.from(revisions)
|
||||
.where(and(
|
||||
eq(revisions.scriptId, input.scriptId),
|
||||
eq(revisions.branchName, input.sourceBranch)
|
||||
))
|
||||
.orderBy(desc(revisions.versionNumber))
|
||||
.limit(1);
|
||||
|
||||
if (sourceRevisions.length === 0) {
|
||||
throw new Error(`Source branch '${input.sourceBranch}' has no revisions`);
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: `Source branch '${input.sourceBranch}' has no revisions` });
|
||||
}
|
||||
|
||||
const sourceContent = sourceRevisions[0]!.content;
|
||||
const nextVersion = getLatestVersionForScript(
|
||||
const nextVersion = await getLatestVersionForScript(
|
||||
ctx.db,
|
||||
input.scriptId,
|
||||
input.targetBranch
|
||||
) + 1;
|
||||
|
||||
const mergeRevision = {
|
||||
id: getNextRevisionId(),
|
||||
scriptId: input.scriptId,
|
||||
versionNumber: nextVersion,
|
||||
branchName: input.targetBranch,
|
||||
parentRevisionId: null,
|
||||
title: `Merge from '${input.sourceBranch}'`,
|
||||
summary: `Merged ${input.sourceBranch} into ${input.targetBranch}`,
|
||||
content: sourceContent,
|
||||
authorId: ctx.userId,
|
||||
status: 'draft' as const,
|
||||
reviewedById: null,
|
||||
reviewedAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
const nextId = await getNextRevisionId(ctx.db);
|
||||
|
||||
revisions.set(mergeRevision.id, mergeRevision);
|
||||
return mergeRevision;
|
||||
const mergeRevision = await ctx.db
|
||||
.insert(revisions)
|
||||
.values({
|
||||
id: nextId,
|
||||
scriptId: input.scriptId,
|
||||
versionNumber: nextVersion,
|
||||
branchName: input.targetBranch,
|
||||
parentRevisionId: null,
|
||||
title: `Merge from '${input.sourceBranch}'`,
|
||||
summary: `Merged ${input.sourceBranch} into ${input.targetBranch}`,
|
||||
content: sourceContent,
|
||||
authorId: ctx.userId,
|
||||
status: 'draft',
|
||||
reviewedById: null,
|
||||
reviewedAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
return mergeRevision[0]!;
|
||||
}),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user