All files / dal social-drafts.dal.ts

96.42% Statements 27/28
100% Branches 12/12
91.66% Functions 11/12
96% Lines 24/25

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119              23x     1x       1x       2x         2x             4x           4x 5x       5x 4x 4x 3x       3x 2x       4x 5x                         3x       3x 1x   3x                     3x                         2x 2x         1x                            
// Data Access Layer for Social Drafts
import { eq, inArray } from "drizzle-orm";
import type { DrizzleD1Database } from "drizzle-orm/d1";
import type { NewSocialDraft, SocialDraft } from "../db/schema";
import * as schema from "../db/schema";
 
export class SocialDraftsDal {
	constructor(private db: DrizzleD1Database<typeof schema>) {}
 
	async create(data: NewSocialDraft): Promise<SocialDraft> {
		const result = await this.db
			.insert(schema.socialDrafts)
			.values(data)
			.returning();
		return result[0];
	}
 
	async getById(id: string): Promise<SocialDraft | null> {
		const result = await this.db
			.select()
			.from(schema.socialDrafts)
			.where(eq(schema.socialDrafts.id, id))
			.limit(1);
		return result[0] ?? null;
	}
 
	async listByPro(proId: string): Promise<{
		drafts: (SocialDraft & { projectTitle: string | null })[];
		pendingCount: number;
	}> {
		const drafts = await this.db
			.select()
			.from(schema.socialDrafts)
			.where(eq(schema.socialDrafts.proId, proId))
			.orderBy(schema.socialDrafts.dateCreated);
 
		const pendingCount = drafts.filter(
			(d) => d.status === "pending" || d.status === "processing",
		).length;
 
		// Fetch project titles for all drafts (avoid JOIN per D1 guidance)
		const projectIds = [...new Set(drafts.map((d) => d.projectId))];
		const projectTitleMap: Record<string, string> = {};
		if (projectIds.length > 0) {
			const projects = await this.db
				.select({ id: schema.projects.id, title: schema.projects.title })
				.from(schema.projects)
				.where(inArray(schema.projects.id, projectIds));
			for (const p of projects) {
				projectTitleMap[p.id] = p.title;
			}
		}
 
		return {
			drafts: drafts.map((d) => ({
				...d,
				projectTitle: projectTitleMap[d.projectId] ?? null,
			})),
			pendingCount,
		};
	}
 
	async updateStatus(
		id: string,
		status: string,
		jobNonce?: string,
	): Promise<void> {
		const update: Partial<typeof schema.socialDrafts.$inferInsert> = {
			status: status as SocialDraft["status"],
			dateUpdated: new Date(),
		};
		if (jobNonce !== undefined) {
			update.jobNonce = jobNonce;
		}
		await this.db
			.update(schema.socialDrafts)
			.set(update)
			.where(eq(schema.socialDrafts.id, id));
	}
 
	// Update draft fields (used by internal callback routes)
	async update(
		id: string,
		data: Partial<Omit<NewSocialDraft, "id" | "proId" | "projectId">>,
	): Promise<void> {
		await this.db
			.update(schema.socialDrafts)
			.set({
				...data,
				dateUpdated: new Date(),
			})
			.where(eq(schema.socialDrafts.id, id));
	}
 
	// Insert the selected photo rows for a draft, ordered by selection index.
	// Kept in the DAL so routes don't reach for `db.insert` directly. The caller
	// owns rollback (delete the draft) if this throws — see social-drafts route.
	async addPhotos(draftId: string, mediaIds: number[]): Promise<void> {
		if (mediaIds.length === 0) return;
		const rows = mediaIds.map((mediaId, index) => ({
			draftId,
			mediaId,
			sortOrder: index,
		}));
		await this.db.insert(schema.socialDraftPhotos).values(rows);
	}
 
	// Hard-delete a draft. Used by C2 rollback when queue.send fails AFTER
	// the draft row is created — we rip the draft back out so the pro
	// receives a clean 503 instead of a stuck-pending draft. FK ON DELETE
	// CASCADE on social_draft_photos cleans up any photo rows that were
	// already inserted.
	async deleteById(id: string): Promise<void> {
		await this.db
			.delete(schema.socialDrafts)
			.where(eq(schema.socialDrafts.id, id));
	}
}