diff --git a/scripts/seed-demo.ts b/scripts/seed-demo.ts new file mode 100644 index 0000000..8ffe196 --- /dev/null +++ b/scripts/seed-demo.ts @@ -0,0 +1,47 @@ +/** + * Fictional data (3 clients, 5 projects, ~60 days of time entries) for manual testing. + * npx tsx scripts/seed-demo.ts --email vinicius3w@gmail.com --workspace "ASSERT Lab" # dry-run (default) + * npx tsx scripts/seed-demo.ts --email vinicius3w@gmail.com --workspace "ASSERT Lab" --apply # writes + * The database comes from DATABASE_URL (use `read -s` to pass production's, see the PR). Idempotent: a workspace that + * already has the "demo" tag is skipped. Prints only the database host, never the connection string. + */ +import { and, eq, isNull, sql } from "drizzle-orm"; +import { getDb } from "@/db"; +import { users, workspaceMembers, workspaces } from "@/db/schema"; +import { getEnv } from "@/env"; +import { seedDemo } from "@/server/seed-demo"; + +function arg(name: string) { + const i = process.argv.indexOf(`--${name}`); + return i >= 0 ? process.argv[i + 1] : undefined; +} + +async function main() { + const email = arg("email")?.trim().toLowerCase(); + const workspaceName = arg("workspace")?.trim(); + const apply = process.argv.includes("--apply"); + if (!email || !workspaceName) throw new Error('Usage: seed-demo.ts --email --workspace "" [--apply]'); + + const db = getDb(); + console.log(`Database host: ${new URL(getEnv().DATABASE_URL).host}`); + console.log(apply ? "Mode: APPLY (writes)" : "Mode: dry-run (nothing is written; pass --apply to write)"); + + const [user] = await db.select().from(users).where(and(sql`lower(${users.email}) = ${email}`, isNull(users.deletedAt))); + if (!user) throw new Error(`No active user with e-mail ${email}`); + const matches = await db + .select({ id: workspaces.id, name: workspaces.name, timezone: users.timezone }) + .from(workspaces) + .innerJoin(workspaceMembers, eq(workspaceMembers.workspaceId, workspaces.id)) + .innerJoin(users, eq(users.id, workspaceMembers.userId)) + .where(and(eq(users.id, user.id), isNull(workspaceMembers.removedAt), isNull(workspaces.archivedAt), sql`lower(${workspaces.name}) = ${workspaceName.toLowerCase()}`)); + if (matches.length !== 1) throw new Error(`Expected exactly one workspace named "${workspaceName}" for ${email}, found ${matches.length}`); + + const result = await seedDemo(db, { workspaceId: matches[0].id, userId: user.id, timezone: matches[0].timezone, apply }); + console.log(JSON.stringify({ workspace: matches[0].name, user: email, ...result }, null, 2)); + if (result.skipped) console.log('Skipped: this workspace already has the "demo" tag.'); +} + +main().catch((e) => { + console.error(e instanceof Error ? e.message : e); + process.exit(1); +}); diff --git a/src/server/seed-demo.test.ts b/src/server/seed-demo.test.ts new file mode 100644 index 0000000..99c3231 --- /dev/null +++ b/src/server/seed-demo.test.ts @@ -0,0 +1,92 @@ +import { and, eq } from "drizzle-orm"; +import { beforeEach, describe, expect, it } from "vitest"; +import type { Db } from "@/db"; +import { organizations, projects, tags, timeEntries, timeEntryTags } from "@/db/schema"; +import { localDateString } from "@/lib/time"; +import { createTestDb, seedWorkspace, type TestDb } from "@/test/db"; +import { planDay, seedDemo } from "./seed-demo"; + +const TZ = "America/Recife"; +const NOW = new Date("2026-10-09T15:00:00Z"); + +describe("seedDemo", () => { + let db: TestDb; + let a: { workspaceId: string; userId: string }; + let b: { workspaceId: string; userId: string }; + const run = (apply: boolean, who = a) => seedDemo(db as unknown as Db, { ...who, timezone: TZ, today: NOW, apply }); + + beforeEach(async () => { + db = await createTestDb(); + a = await seedWorkspace(db, "sa"); + b = await seedWorkspace(db, "sb"); + }); + + it("dry-run reports the plan and writes nothing", async () => { + const r = await run(false); + expect(r).toMatchObject({ applied: false, skipped: false, clients: 3, projects: 5 }); + expect(r.entries).toBeGreaterThan(80); + expect(await db.select().from(organizations)).toHaveLength(0); + expect(await db.select().from(timeEntries)).toHaveLength(0); + }); + + it("applies: catalog, tagged entries, only weekdays before today, closed and non-overlapping", async () => { + const r = await run(true); + expect(r.applied).toBe(true); + const entries = await db.select().from(timeEntries).where(eq(timeEntries.workspaceId, a.workspaceId)); + expect(entries).toHaveLength(r.entries); + const today = localDateString(NOW, TZ); + for (const e of entries) { + expect(e.endedAt).not.toBeNull(); + expect(e.endedAt! > e.startedAt).toBe(true); + const day = localDateString(e.startedAt, TZ); + expect(day < today).toBe(true); + expect([0, 6]).not.toContain(new Date(`${day}T12:00:00Z`).getUTCDay()); + } + const sorted = [...entries].sort((x, y) => x.startedAt.getTime() - y.startedAt.getTime()); + sorted.slice(1).forEach((e, i) => expect(e.startedAt >= sorted[i].endedAt!).toBe(true)); + expect(entries.some((e) => e.projectId === null)).toBe(true); // exercises the "without project" warning + expect(entries.some((e) => !e.isBillable)).toBe(true); + const demo = await db.select().from(tags).where(and(eq(tags.workspaceId, a.workspaceId), eq(tags.name, "demo"))); + const tagged = await db.select().from(timeEntryTags).where(eq(timeEntryTags.tagId, demo[0].id)); + expect(tagged).toHaveLength(r.entries); + }); + + it("is idempotent and stays inside the target workspace", async () => { + await run(true); + const again = await run(true); + expect(again.skipped).toBe(true); + expect(await db.select().from(timeEntries).where(eq(timeEntries.workspaceId, a.workspaceId))).toHaveLength((await run(false)).entries); + expect(await db.select().from(projects).where(eq(projects.workspaceId, b.workspaceId))).toHaveLength(0); + expect(await db.select().from(timeEntries).where(eq(timeEntries.workspaceId, b.workspaceId))).toHaveLength(0); + }); + + it("can seed a second workspace independently (same client names)", async () => { + await run(true); + expect((await run(true, b)).applied).toBe(true); + expect(await db.select().from(organizations)).toHaveLength(6); + }); + + it("recovers from a run that died before the marker tag, without duplicating entries", async () => { + const full = await run(true); + await db.delete(tags).where(and(eq(tags.workspaceId, a.workspaceId), eq(tags.name, "demo"))); // simulate the interrupted run + await run(true); + expect(await db.select().from(timeEntries).where(eq(timeEntries.workspaceId, a.workspaceId))).toHaveLength(full.entries); + }); +}); + +describe("planDay", () => { + it("never overlaps, skips lunch and ends before 19:00", () => { + let seed = 1; + const rand = () => ((seed = (seed * 16807) % 2147483647) / 2147483647); + for (let i = 0; i < 200; i++) { + const slots = planDay("2026-10-05", rand); + expect(slots.length).toBeGreaterThanOrEqual(1); + slots.forEach((s, j) => { + expect(s.endMin).toBeGreaterThan(s.startMin); + expect(s.endMin).toBeLessThanOrEqual(19 * 60); + expect(s.startMin < 12 * 60 && s.endMin > 12 * 60).toBe(false); + if (j > 0) expect(s.startMin).toBeGreaterThanOrEqual(slots[j - 1].endMin); + }); + } + }); +}); diff --git a/src/server/seed-demo.ts b/src/server/seed-demo.ts new file mode 100644 index 0000000..19e2527 --- /dev/null +++ b/src/server/seed-demo.ts @@ -0,0 +1,180 @@ +import { and, eq } from "drizzle-orm"; +import type { Db } from "@/db"; +import { organizations, projectMembers, projects, tags, tasks, timeEntries, timeEntryTags, workspaceMembers } from "@/db/schema"; +import { addDays, localDateString, zonedTimeToUtc } from "@/lib/time"; + +/** Fictional data for manual testing. Everything is tagged "demo"; client names end with " (demo)". */ +export const DEMO_TAG = "demo"; + +const CLIENTS = [ + { + name: "ASSERT Lab (demo)", + projects: [ + { name: "Pesquisa em qualidade de software", color: "#8B5CF6", billable: false, tasks: ["Revisão de literatura", "Escrita de artigo", "Reunião de orientação"] }, + { name: "Infraestrutura do laboratório", color: "#64748B", billable: false, tasks: ["Manutenção de servidores", "Documentação"] }, + ], + }, + { + name: "Acme Consultoria (demo)", + projects: [ + { name: "Portal do cliente", color: "#3B82F6", billable: true, tasks: ["Levantamento de requisitos", "Desenvolvimento front-end", "Revisão de código", "Homologação"] }, + { name: "Migração de dados", color: "#0EA5E9", billable: true, tasks: ["Mapeamento de tabelas", "Scripts de carga", "Validação"] }, + ], + }, + { + name: "Nova Energia (demo)", + projects: [{ name: "Painel de indicadores", color: "#10B981", billable: true, tasks: ["Modelagem dos dados", "Dashboards", "Treinamento da equipe"] }], + }, +] as const; + +const DESCRIPTIONS = ["Ajustes e correções", "Implementação", "Análise e planejamento", "Reunião de alinhamento", "Revisão", "Testes", "Documentação", "Pesquisa"]; +const TAGS = [DEMO_TAG, "reunião", "desenvolvimento", "pesquisa"]; + +/** Small deterministic PRNG (mulberry32): the same seed always produces the same data. */ +function prng(seed: number) { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const hhmm = (minutes: number) => `${String(Math.floor(minutes / 60)).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}`; + +type Slot = { date: string; startMin: number; endMin: number }; + +/** 3 to 5 non-overlapping blocks per weekday, around lunch, between 08:00 and 19:00 local time. */ +export function planDay(date: string, rand: () => number): Slot[] { + const slots: Slot[] = []; + let cursor = 8 * 60 + Math.floor(rand() * 5) * 6; + const blocks = 3 + Math.floor(rand() * 3); + for (let i = 0; i < blocks; i++) { + const duration = 45 + Math.floor(rand() * 11) * 9; // 45..135 min + let end = cursor + duration; + if (cursor < 12 * 60 && end > 12 * 60) { + cursor = 13 * 60 + 30; + end = cursor + duration; + } + if (end > 19 * 60) break; + slots.push({ date, startMin: cursor, endMin: end }); + cursor = end + 5 + Math.floor(rand() * 5) * 5; + } + return slots; +} + +export type DemoPlan = { clients: number; projects: number; tasks: number; entries: number; hours: number }; +export type DemoResult = DemoPlan & { applied: boolean; skipped: boolean }; + +const isWeekday = (date: string) => ![0, 6].includes(new Date(`${date}T12:00:00Z`).getUTCDay()); + +export async function seedDemo( + db: Db, + { workspaceId, userId, timezone = "America/Recife", today = new Date(), days = 60, apply = false }: { workspaceId: string; userId: string; timezone?: string; today?: Date; days?: number; apply?: boolean }, +): Promise { + const rand = prng(20261009); + const lastDay = addDays(localDateString(today, timezone), -1); // never today: no clash with a real running timer + const dates = Array.from({ length: days }, (_, i) => addDays(lastDay, -(days - 1 - i))).filter(isWeekday); + + const projectList = CLIENTS.flatMap((c) => c.projects.map((p) => ({ client: c.name, ...p }))); + const rows = dates.flatMap((date) => + planDay(date, rand).map((slot) => { + const withoutProject = rand() < 0.06; + const project = projectList[Math.floor(rand() * projectList.length)]; + const task = project.tasks[Math.floor(rand() * project.tasks.length)]; + const description = DESCRIPTIONS[Math.floor(rand() * DESCRIPTIONS.length)]; + const meeting = description.startsWith("Reunião"); + return { + startedAt: zonedTimeToUtc(slot.date, hhmm(slot.startMin), timezone), + endedAt: zonedTimeToUtc(slot.date, hhmm(slot.endMin), timezone), + project: withoutProject ? null : project, + task: withoutProject ? null : task, + description: withoutProject ? "Triagem de e-mails" : description, + isBillable: !withoutProject && project.billable && !meeting, + tagNames: [DEMO_TAG, ...(meeting ? ["reunião"] : []), ...(!meeting && project.billable ? ["desenvolvimento"] : []), ...(project.name.startsWith("Pesquisa") ? ["pesquisa"] : [])], + }; + }), + ); + const plan: DemoPlan = { + clients: CLIENTS.length, + projects: projectList.length, + tasks: projectList.reduce((n, p) => n + p.tasks.length, 0), + entries: rows.length, + hours: Math.round((rows.reduce((s, r) => s + (r.endedAt.getTime() - r.startedAt.getTime()), 0) / 3_600_000) * 10) / 10, + }; + + const [already] = await db.select({ id: tags.id }).from(tags).where(and(eq(tags.workspaceId, workspaceId), eq(tags.name, DEMO_TAG))); + if (already) return { ...plan, applied: false, skipped: true }; + if (!apply) return { ...plan, applied: false, skipped: false }; + + // neon-http has no interactive transactions: dependency order, each step one statement (safe to rerun after a failure + // because the "demo" tag is created last and its presence is what marks the workspace as seeded). + const orgRows = await db + .insert(organizations) + .values(CLIENTS.map((c) => ({ workspaceId, name: c.name }))) + .onConflictDoUpdate({ target: [organizations.workspaceId, organizations.name], set: { isArchived: false } }) + .returning(); + const orgByName = new Map(orgRows.map((o) => [o.name, o.id])); + + const projectRows = await db + .insert(projects) + .values(projectList.map((p) => ({ workspaceId, organizationId: orgByName.get(p.client)!, name: p.name, color: p.color }))) + .onConflictDoUpdate({ target: [projects.organizationId, projects.name], set: { isArchived: false } }) + .returning(); + const projectByName = new Map(projectRows.map((p) => [p.name, p.id])); + + const taskRows = await db + .insert(tasks) + .values(projectList.flatMap((p) => p.tasks.map((name) => ({ workspaceId, projectId: projectByName.get(p.name)!, name })))) + .onConflictDoUpdate({ target: [tasks.projectId, tasks.name], set: { isCompleted: false } }) + .returning(); + const taskKey = (projectId: string, name: string) => `${projectId}|${name}`; + const taskByKey = new Map(taskRows.map((t) => [taskKey(t.projectId, t.name), t.id])); + + // Everyone who is already an active member of the workspace takes part in the demo projects. + const members = await db.select({ userId: workspaceMembers.userId }).from(workspaceMembers).where(eq(workspaceMembers.workspaceId, workspaceId)); + await db + .insert(projectMembers) + .values(members.flatMap((m) => projectRows.map((p) => ({ workspaceId, projectId: p.id, userId: m.userId })))) + .onConflictDoNothing(); + + // A previous run may have died after inserting entries but before the marker tag: never insert the same entry twice. + const existing = await db.select({ startedAt: timeEntries.startedAt }).from(timeEntries).where(and(eq(timeEntries.workspaceId, workspaceId), eq(timeEntries.userId, userId))); + const taken = new Set(existing.map((e) => e.startedAt.getTime())); + const fresh = rows.filter((r) => !taken.has(r.startedAt.getTime())); + for (let i = 0; i < fresh.length; i += 100) { + const chunk = fresh.slice(i, i + 100); + await db.insert(timeEntries).values( + chunk.map((r) => { + const projectId = r.project ? projectByName.get(r.project.name)! : null; + return { + workspaceId, + userId, + projectId, + taskId: projectId && r.task ? taskByKey.get(taskKey(projectId, r.task))! : null, + description: r.description, + startedAt: r.startedAt, + endedAt: r.endedAt, + timezone, + isBillable: r.isBillable, + }; + }), + ); + } + + // The "demo" tag is the marker of a finished seed: created after the entries, then attached to them. + await db.insert(tags).values(TAGS.map((name) => ({ workspaceId, name }))).onConflictDoNothing(); + // Read them back: `returning()` would only list the new rows, and the workspace may already have tags such as "reunião". + const tagRows = await db.select().from(tags).where(eq(tags.workspaceId, workspaceId)); + const tagId = new Map(tagRows.map((t) => [t.name, t.id])); + const created = await db.select({ id: timeEntries.id, startedAt: timeEntries.startedAt }).from(timeEntries).where(and(eq(timeEntries.workspaceId, workspaceId), eq(timeEntries.userId, userId))); + const idByStart = new Map(created.map((e) => [e.startedAt.getTime(), e.id])); + const links = rows.flatMap((r) => { + const entryId = idByStart.get(r.startedAt.getTime()); + return entryId ? r.tagNames.map((n) => ({ timeEntryId: entryId, tagId: tagId.get(n)! })) : []; + }); + for (let i = 0; i < links.length; i += 200) await db.insert(timeEntryTags).values(links.slice(i, i + 200)).onConflictDoNothing(); + return { ...plan, applied: true, skipped: false }; +}