All files / dal/rrm links.dal.ts

100% Statements 20/20
100% Branches 8/8
100% Functions 8/8
100% Lines 18/18

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 120 121 122 123 124 125 126 127 128 129          18x                                       2x 2x 2x         1x 1x 1x         67x                             5x         5x                   16x         16x   14x                     14x                 6x         6x         3x                               5x            
import { and, desc, eq, sql } from "drizzle-orm";
import type { DrizzleD1Database } from "drizzle-orm/d1";
import type { RrmLink } from "../../db/schema";
import * as schema from "../../db/schema";
 
const LINKS = schema.rrmLinks;
 
/**
 * Short links behind `go.interioring.com/l/:slug` (build spec §1.6).
 *
 * The destination is resolved here and 302'd by the Go worker — never 301, so
 * a swapped destination takes effect immediately instead of living in a
 * browser cache forever.
 */
 
export type CreateLinkInput = {
	/** The `:slug` in `/l/:slug`. */
	slug: string;
	/** Absolute URL to 302 to. */
	destination: string;
	/** Operator-facing name, e.g. "Kokapet WhatsApp group post". */
	label?: string | null;
};
 
export class DuplicateLinkError extends Error {
	code = "DUPLICATE_SLUG";
	constructor(public slug: string) {
		super(`Link slug "${slug}" already exists`);
	}
}
 
export class LinkNotFoundError extends Error {
	code = "NOT_FOUND";
	constructor(public slug: string) {
		super(`Link slug "${slug}" not found`);
	}
}
 
export class RrmLinksDal {
	constructor(private db: DrizzleD1Database<typeof schema>) {}
 
	/**
	 * The destination for a live slug, or `null`.
	 *
	 * Unknown and deactivated slugs are the same answer on purpose. §1.6: the
	 * caller redirects to the marketing home and logs `link_miss` — it never
	 * shows an error page. Someone who followed a retired link from a WhatsApp
	 * group six weeks ago is a prospect, and a 404 is a worse first impression
	 * than the homepage.
	 *
	 * `active` is filtered in the WHERE clause rather than checked after the
	 * fetch so the two states cannot diverge in a caller that forgets.
	 */
	async resolve(slug: string): Promise<string | null> {
		const rows = await this.db
			.select({ destination: LINKS.destination })
			.from(LINKS)
			.where(and(eq(LINKS.slug, slug), eq(LINKS.active, true)))
			.limit(1);
		return rows[0]?.destination ?? null;
	}
 
	/**
	 * The slug is the primary key, so a duplicate is checked up front rather
	 * than caught: D1 and the test engine surface constraint violations as
	 * differently-shaped raw errors, and matching on either one is a bug that
	 * only appears in the environment you did not test in.
	 */
	async create(input: CreateLinkInput): Promise<RrmLink> {
		const existing = await this.db
			.select({ slug: LINKS.slug })
			.from(LINKS)
			.where(eq(LINKS.slug, input.slug))
			.limit(1);
		if (existing.length > 0) throw new DuplicateLinkError(input.slug);
 
		const inserted = await this.db
			.insert(LINKS)
			.values({
				slug: input.slug,
				destination: input.destination,
				label: input.label ?? null,
				active: true,
				clickCount: 0,
				dateCreated: new Date(),
			})
			.returning();
		return inserted[0];
	}
 
	/**
	 * Retire a slug without deleting it. The row stays so its accumulated
	 * `clickCount` survives, and so a slug is never silently recycled for a
	 * different destination while old posts still point at it.
	 */
	async deactivate(slug: string): Promise<void> {
		const updated = await this.db
			.update(LINKS)
			.set({ active: false })
			.where(eq(LINKS.slug, slug))
			.returning({ slug: LINKS.slug });
		if (updated.length === 0) throw new LinkNotFoundError(slug);
	}
 
	/** Newest first — the admin screen is mostly "what did I just make". */
	async list(): Promise<RrmLink[]> {
		return await this.db.select().from(LINKS).orderBy(desc(LINKS.dateCreated));
	}
 
	/**
	 * Count one click.
	 *
	 * The increment happens SQL-side, never read-modify-write: clicks arrive
	 * concurrently and a read-then-write would drop every collision.
	 *
	 * The CALLER reports the click under `waitUntil` and never awaits it — the
	 * 302 has already gone out by then. So this rejecting must not, and cannot,
	 * fail the redirect. An unknown slug is a silent no-op for the same reason;
	 * the miss is recorded as a `link_miss` event by the caller, not as a
	 * throw here.
	 */
	async recordClick(slug: string): Promise<void> {
		await this.db
			.update(LINKS)
			.set({ clickCount: sql`${LINKS.clickCount} + 1` })
			.where(eq(LINKS.slug, slug));
	}
}