@pylonsync/create-pylon 0.3.275 → 0.3.276
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/create-pylon.js +1 -1
- package/package.json +1 -1
- package/templates/agency/app/dashboard/dashboard-client.tsx +1213 -59
- package/templates/agency/app/layout.tsx +1 -1
- package/templates/agency/app/page.tsx +72 -30
- package/templates/agency/app/seeder.tsx +26 -0
- package/templates/agency/app/work/[slug]/page.tsx +182 -0
- package/templates/agency/app/work/page.tsx +83 -0
- package/templates/agency/app.ts +168 -19
- package/templates/agency/components/marketing.tsx +39 -0
- package/templates/agency/functions/clientsForOwner.ts +27 -0
- package/templates/agency/functions/deleteClient.ts +27 -0
- package/templates/agency/functions/deleteInvoice.ts +19 -0
- package/templates/agency/functions/deleteProject.ts +20 -0
- package/templates/agency/functions/invoicesForOwner.ts +27 -0
- package/templates/agency/functions/seedProjects.ts +41 -0
- package/templates/agency/functions/seedStudioBackoffice.ts +74 -0
- package/templates/agency/functions/setInvoiceStatus.ts +27 -0
- package/templates/agency/functions/setProjectFlags.ts +35 -0
- package/templates/agency/functions/upsertClient.ts +73 -0
- package/templates/agency/functions/upsertInvoice.ts +113 -0
- package/templates/agency/functions/upsertProject.ts +97 -0
- package/templates/agency/lib/agency.ts +165 -3
- package/templates/agency/lib/invoice-pdf.tsx +174 -0
- package/templates/agency/lib/site.config.ts +180 -1
- package/templates/agency/package.json +2 -1
- package/templates/ai-chat/app/chat-client.tsx +354 -41
- package/templates/ai-chat/functions/deleteConversation.ts +33 -0
- package/templates/ai-studio/app/studio-client.tsx +172 -29
- package/templates/ai-studio/app.ts +7 -7
- package/templates/ai-studio/lib/studio.ts +5 -5
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
+
import { Link } from "@pylonsync/react";
|
|
3
|
+
import type { ProjectView } from "@/lib/agency";
|
|
2
4
|
|
|
3
5
|
// Reusable presentational pieces for the landing page. All server-rendered —
|
|
4
6
|
// no client JS. Restyle here and the whole page follows. The brand accent
|
|
@@ -95,6 +97,43 @@ export function initials(name: string) {
|
|
|
95
97
|
.toUpperCase();
|
|
96
98
|
}
|
|
97
99
|
|
|
100
|
+
// A linked portfolio card — the project's cover, title, client label, summary,
|
|
101
|
+
// and tag chips, linking to its /work/[slug] case study. Used on the homepage
|
|
102
|
+
// "Selected work" grid and the full /work index, so both stay in lockstep.
|
|
103
|
+
export function ProjectCard({ p }: { p: ProjectView }) {
|
|
104
|
+
return (
|
|
105
|
+
<Link href={`/work/${p.slug}`} className="group block">
|
|
106
|
+
{/* Case-study cover — drop in a real project screenshot. */}
|
|
107
|
+
<ImagePlaceholder
|
|
108
|
+
shape="landscape"
|
|
109
|
+
title={`${p.title} — project shot`}
|
|
110
|
+
hint="Swap for an <img> per case study"
|
|
111
|
+
/>
|
|
112
|
+
<div className="mt-4 flex items-baseline justify-between gap-3">
|
|
113
|
+
<h3 className="text-[16px] font-semibold text-zinc-900 transition-colors group-hover:text-brand">
|
|
114
|
+
{p.title}
|
|
115
|
+
</h3>
|
|
116
|
+
<span className="shrink-0 font-mono text-[11px] uppercase tracking-wide text-zinc-400">
|
|
117
|
+
{p.client}
|
|
118
|
+
</span>
|
|
119
|
+
</div>
|
|
120
|
+
<p className="mt-1.5 text-[14px] leading-relaxed text-zinc-500">{p.summary}</p>
|
|
121
|
+
{p.tags.length > 0 ? (
|
|
122
|
+
<div className="mt-3 flex flex-wrap gap-1.5">
|
|
123
|
+
{p.tags.map((t) => (
|
|
124
|
+
<span key={t} className="rounded-full bg-zinc-100 px-2.5 py-0.5 text-[11px] font-medium text-zinc-600">
|
|
125
|
+
{t}
|
|
126
|
+
</span>
|
|
127
|
+
))}
|
|
128
|
+
</div>
|
|
129
|
+
) : null}
|
|
130
|
+
<span className="mt-3 inline-flex items-center gap-1 text-[13px] font-medium text-brand opacity-0 transition-opacity group-hover:opacity-100">
|
|
131
|
+
Read case study →
|
|
132
|
+
</span>
|
|
133
|
+
</Link>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
98
137
|
// A deliberately-obvious image placeholder. Real sites drop a photo here; this
|
|
99
138
|
// makes the spot unmistakable — dashed border, a photo glyph, and a one-line
|
|
100
139
|
// "swap this" instruction telling you exactly what to replace and where. Looks
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { query } from "@pylonsync/functions";
|
|
2
|
+
import { emailMatchesOwner } from "../lib/owner";
|
|
3
|
+
import type { ClientRow, OwnerClientsResult } from "../lib/agency";
|
|
4
|
+
|
|
5
|
+
// clientsForOwner — the owner's CRM. Client rows hold contact PII (name, email,
|
|
6
|
+
// phone), so the entity denies all client access; this owner-gated query is the
|
|
7
|
+
// only way to read them. A query has no `ctx.error`, so a non-owner gets
|
|
8
|
+
// `{ authorized: false }` (a bare throw would surface as a stripped
|
|
9
|
+
// HANDLER_ERROR) and NO data. The dashboard calls it with `callFn` and refetches
|
|
10
|
+
// after any write.
|
|
11
|
+
export default query({
|
|
12
|
+
auth: "user",
|
|
13
|
+
async handler(ctx): Promise<OwnerClientsResult> {
|
|
14
|
+
const me = await ctx.db.get("User", ctx.auth.userId);
|
|
15
|
+
const email = (me?.email as string | undefined) ?? null;
|
|
16
|
+
if (!emailMatchesOwner(email, ctx.env.PYLON_OWNER_EMAIL)) {
|
|
17
|
+
return { authorized: false };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const rows = (await ctx.db.unsafe.list("Client")) as unknown as ClientRow[];
|
|
21
|
+
const clients = rows
|
|
22
|
+
.map((r) => ({ ...r }))
|
|
23
|
+
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0));
|
|
24
|
+
|
|
25
|
+
return { authorized: true, clients };
|
|
26
|
+
},
|
|
27
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { mutation, v } from "@pylonsync/functions";
|
|
2
|
+
import { emailMatchesOwner } from "../lib/owner";
|
|
3
|
+
import type { InvoiceRow } from "../lib/agency";
|
|
4
|
+
|
|
5
|
+
// deleteClient — owner-only. Refuses to delete a client that still has invoices
|
|
6
|
+
// (a financial record must not lose its counterparty); the owner deletes or
|
|
7
|
+
// reassigns those invoices first. Returns `{ ok: false, invoices: n }` so the
|
|
8
|
+
// dashboard can explain why.
|
|
9
|
+
export default mutation<{ id: string }, { ok: boolean; invoices: number }>({
|
|
10
|
+
auth: "user",
|
|
11
|
+
args: { id: v.id("Client") },
|
|
12
|
+
async handler(ctx, args) {
|
|
13
|
+
const me = await ctx.db.get("User", ctx.auth.userId);
|
|
14
|
+
if (!emailMatchesOwner(me?.email as string | undefined, ctx.env.PYLON_OWNER_EMAIL)) {
|
|
15
|
+
throw ctx.error("POLICY_DENIED", "Only the owner can manage clients.");
|
|
16
|
+
}
|
|
17
|
+
const client = await ctx.db.get("Client", args.id);
|
|
18
|
+
if (!client) return { ok: true, invoices: 0 };
|
|
19
|
+
|
|
20
|
+
const invoices = (await ctx.db.unsafe.list("Invoice")) as unknown as InvoiceRow[];
|
|
21
|
+
const linked = invoices.filter((i) => i.clientId === args.id).length;
|
|
22
|
+
if (linked > 0) return { ok: false, invoices: linked };
|
|
23
|
+
|
|
24
|
+
await ctx.db.unsafe.delete("Client", args.id);
|
|
25
|
+
return { ok: true, invoices: 0 };
|
|
26
|
+
},
|
|
27
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { mutation, v } from "@pylonsync/functions";
|
|
2
|
+
import { emailMatchesOwner } from "../lib/owner";
|
|
3
|
+
|
|
4
|
+
// deleteInvoice — owner-only. Removes a bill outright (e.g. a draft created in
|
|
5
|
+
// error). No cascade — invoices don't own anything.
|
|
6
|
+
export default mutation<{ id: string }, { ok: boolean }>({
|
|
7
|
+
auth: "user",
|
|
8
|
+
args: { id: v.id("Invoice") },
|
|
9
|
+
async handler(ctx, args) {
|
|
10
|
+
const me = await ctx.db.get("User", ctx.auth.userId);
|
|
11
|
+
if (!emailMatchesOwner(me?.email as string | undefined, ctx.env.PYLON_OWNER_EMAIL)) {
|
|
12
|
+
throw ctx.error("POLICY_DENIED", "Only the owner can manage invoices.");
|
|
13
|
+
}
|
|
14
|
+
const invoice = await ctx.db.get("Invoice", args.id);
|
|
15
|
+
if (!invoice) return { ok: true };
|
|
16
|
+
await ctx.db.unsafe.delete("Invoice", args.id);
|
|
17
|
+
return { ok: true };
|
|
18
|
+
},
|
|
19
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { mutation, v } from "@pylonsync/functions";
|
|
2
|
+
import { emailMatchesOwner } from "../lib/owner";
|
|
3
|
+
|
|
4
|
+
// deleteProject — owner-only. Removes a case study. Any invoice that referenced
|
|
5
|
+
// it keeps its denormalized `projectTitle` (so the bill still reads sensibly),
|
|
6
|
+
// it just no longer links anywhere — we don't cascade-delete money records.
|
|
7
|
+
export default mutation<{ id: string }, { ok: boolean }>({
|
|
8
|
+
auth: "user",
|
|
9
|
+
args: { id: v.id("Project") },
|
|
10
|
+
async handler(ctx, args) {
|
|
11
|
+
const me = await ctx.db.get("User", ctx.auth.userId);
|
|
12
|
+
if (!emailMatchesOwner(me?.email as string | undefined, ctx.env.PYLON_OWNER_EMAIL)) {
|
|
13
|
+
throw ctx.error("POLICY_DENIED", "Only the owner can manage projects.");
|
|
14
|
+
}
|
|
15
|
+
const project = await ctx.db.get("Project", args.id);
|
|
16
|
+
if (!project) return { ok: true };
|
|
17
|
+
await ctx.db.unsafe.delete("Project", args.id);
|
|
18
|
+
return { ok: true };
|
|
19
|
+
},
|
|
20
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { query } from "@pylonsync/functions";
|
|
2
|
+
import { emailMatchesOwner } from "../lib/owner";
|
|
3
|
+
import type { InvoiceRow, OwnerInvoicesResult } from "../lib/agency";
|
|
4
|
+
|
|
5
|
+
// invoicesForOwner — the owner's billing. Invoices hold money + client data, so
|
|
6
|
+
// the entity denies all client access; this owner-gated query is the only way
|
|
7
|
+
// to read them. Like the other owner queries it returns `{ authorized: false }`
|
|
8
|
+
// for a non-owner (a query has no `ctx.error`) rather than throwing. Sorted
|
|
9
|
+
// newest-first by issue date, falling back to creation time.
|
|
10
|
+
export default query({
|
|
11
|
+
auth: "user",
|
|
12
|
+
async handler(ctx): Promise<OwnerInvoicesResult> {
|
|
13
|
+
const me = await ctx.db.get("User", ctx.auth.userId);
|
|
14
|
+
const email = (me?.email as string | undefined) ?? null;
|
|
15
|
+
if (!emailMatchesOwner(email, ctx.env.PYLON_OWNER_EMAIL)) {
|
|
16
|
+
return { authorized: false };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const rows = (await ctx.db.unsafe.list("Invoice")) as unknown as InvoiceRow[];
|
|
20
|
+
const key = (i: InvoiceRow) => i.issuedAt || i.createdAt;
|
|
21
|
+
const invoices = rows
|
|
22
|
+
.map((r) => ({ ...r }))
|
|
23
|
+
.sort((a, b) => (key(a) < key(b) ? 1 : key(a) > key(b) ? -1 : 0));
|
|
24
|
+
|
|
25
|
+
return { authorized: true, invoices };
|
|
26
|
+
},
|
|
27
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { mutation } from "@pylonsync/functions";
|
|
2
|
+
import { siteConfig } from "../lib/site.config";
|
|
3
|
+
import { slugify } from "../lib/agency";
|
|
4
|
+
|
|
5
|
+
// seedProjects — create the portfolio from config on first visit (idempotent).
|
|
6
|
+
// The public pages call it on mount; once any Project row exists it's a no-op,
|
|
7
|
+
// so it's safe to call on every load. A lock keeps two concurrent first-visits
|
|
8
|
+
// from double-seeding.
|
|
9
|
+
//
|
|
10
|
+
// Public so an anonymous first visitor seeds it: Projects are public marketing
|
|
11
|
+
// content (no PII), and this only ever writes the config's case studies.
|
|
12
|
+
export default mutation<Record<string, never>, { seeded: number }>({
|
|
13
|
+
auth: "public",
|
|
14
|
+
async handler(ctx) {
|
|
15
|
+
await ctx.db.advisoryLock("agency_seed_projects");
|
|
16
|
+
const existing = await ctx.db.unsafe.list("Project");
|
|
17
|
+
if (existing.length > 0) return { seeded: 0 };
|
|
18
|
+
|
|
19
|
+
const now = new Date().toISOString();
|
|
20
|
+
let order = 0;
|
|
21
|
+
for (const p of siteConfig.work.items) {
|
|
22
|
+
await ctx.db.unsafe.insert("Project", {
|
|
23
|
+
title: p.title,
|
|
24
|
+
slug: p.slug || slugify(p.title),
|
|
25
|
+
client: p.client,
|
|
26
|
+
summary: p.summary,
|
|
27
|
+
year: p.year ?? null,
|
|
28
|
+
tags: p.tags.join(", "),
|
|
29
|
+
selected: p.selected ?? false,
|
|
30
|
+
published: true,
|
|
31
|
+
order: order++,
|
|
32
|
+
challenge: p.challenge ?? null,
|
|
33
|
+
approach: p.approach ?? null,
|
|
34
|
+
outcome: p.outcome ?? null,
|
|
35
|
+
liveUrl: p.liveUrl ?? null,
|
|
36
|
+
createdAt: now,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
return { seeded: order };
|
|
40
|
+
},
|
|
41
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { mutation } from "@pylonsync/functions";
|
|
2
|
+
import { emailMatchesOwner } from "../lib/owner";
|
|
3
|
+
import { siteConfig } from "../lib/site.config";
|
|
4
|
+
import { lineItemsTotal, slugify, type ProjectRow } from "../lib/agency";
|
|
5
|
+
|
|
6
|
+
// seedStudioBackoffice — owner-only, idempotent. Fills the dashboard's CRM +
|
|
7
|
+
// billing with the demo clients/invoices from config so it isn't an empty shell
|
|
8
|
+
// on first sign-in. Private data (PII + money) is only ever seeded for the
|
|
9
|
+
// signed-in owner — never by an anonymous visitor — which is why this is
|
|
10
|
+
// owner-gated, unlike the public seedProjects.
|
|
11
|
+
//
|
|
12
|
+
// Invoices link to clients by name and to projects by slug; we resolve those to
|
|
13
|
+
// ids here. Projects may already be seeded (the public site calls seedProjects);
|
|
14
|
+
// if a project isn't present yet, the invoice still carries its title and just
|
|
15
|
+
// doesn't deep-link.
|
|
16
|
+
export default mutation<Record<string, never>, { seeded: boolean }>({
|
|
17
|
+
auth: "user",
|
|
18
|
+
async handler(ctx) {
|
|
19
|
+
const me = await ctx.db.get("User", ctx.auth.userId);
|
|
20
|
+
if (!emailMatchesOwner(me?.email as string | undefined, ctx.env.PYLON_OWNER_EMAIL)) {
|
|
21
|
+
throw ctx.error("POLICY_DENIED", "Only the owner can seed the back-office.");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
await ctx.db.advisoryLock("agency_seed_backoffice");
|
|
25
|
+
const existingClients = await ctx.db.unsafe.list("Client");
|
|
26
|
+
if (existingClients.length > 0) return { seeded: false };
|
|
27
|
+
|
|
28
|
+
const now = new Date().toISOString();
|
|
29
|
+
|
|
30
|
+
// Clients first — keep a name → id map for the invoices.
|
|
31
|
+
const clientIdByName = new Map<string, string>();
|
|
32
|
+
for (const c of siteConfig.backoffice.clients) {
|
|
33
|
+
const id = await ctx.db.unsafe.insert("Client", {
|
|
34
|
+
name: c.name,
|
|
35
|
+
company: c.company ?? null,
|
|
36
|
+
email: c.email ?? null,
|
|
37
|
+
phone: c.phone ?? null,
|
|
38
|
+
status: c.status ?? "prospect",
|
|
39
|
+
notes: c.notes ?? null,
|
|
40
|
+
createdAt: now,
|
|
41
|
+
});
|
|
42
|
+
clientIdByName.set(c.name, id);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Resolve project links by slug (config slug, or derived from the title).
|
|
46
|
+
const projects = (await ctx.db.unsafe.list("Project")) as unknown as ProjectRow[];
|
|
47
|
+
const projectBySlug = new Map(projects.map((p) => [p.slug, p]));
|
|
48
|
+
const titleBySlug = new Map(
|
|
49
|
+
siteConfig.work.items.map((p) => [p.slug || slugify(p.title), p.title]),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
for (const inv of siteConfig.backoffice.invoices) {
|
|
53
|
+
const clientId = clientIdByName.get(inv.client);
|
|
54
|
+
if (!clientId) continue; // skip an invoice whose client wasn't seeded
|
|
55
|
+
const project = inv.projectSlug ? projectBySlug.get(inv.projectSlug) : undefined;
|
|
56
|
+
await ctx.db.unsafe.insert("Invoice", {
|
|
57
|
+
number: inv.number,
|
|
58
|
+
clientId,
|
|
59
|
+
clientName: inv.client,
|
|
60
|
+
projectId: project?.id ?? null,
|
|
61
|
+
projectTitle: project?.title ?? (inv.projectSlug ? titleBySlug.get(inv.projectSlug) ?? null : null),
|
|
62
|
+
lineItems: JSON.stringify(inv.lineItems),
|
|
63
|
+
amountCents: lineItemsTotal(inv.lineItems),
|
|
64
|
+
status: inv.status ?? "draft",
|
|
65
|
+
issuedAt: inv.issuedAt ?? null,
|
|
66
|
+
dueAt: inv.dueAt ?? null,
|
|
67
|
+
notes: null,
|
|
68
|
+
createdAt: now,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { seeded: true };
|
|
73
|
+
},
|
|
74
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { mutation, v } from "@pylonsync/functions";
|
|
2
|
+
import { emailMatchesOwner } from "../lib/owner";
|
|
3
|
+
|
|
4
|
+
// setInvoiceStatus — owner-only. Move a bill along its lifecycle
|
|
5
|
+
// (draft → sent → paid, or → overdue) without re-opening the full editor. The
|
|
6
|
+
// dashboard's per-invoice status menu calls this.
|
|
7
|
+
const STATUSES = new Set(["draft", "sent", "paid", "overdue"]);
|
|
8
|
+
|
|
9
|
+
export default mutation<{ id: string; status: string }, { ok: boolean }>({
|
|
10
|
+
auth: "user",
|
|
11
|
+
args: { id: v.id("Invoice"), status: v.string() },
|
|
12
|
+
async handler(ctx, args) {
|
|
13
|
+
const me = await ctx.db.get("User", ctx.auth.userId);
|
|
14
|
+
if (!emailMatchesOwner(me?.email as string | undefined, ctx.env.PYLON_OWNER_EMAIL)) {
|
|
15
|
+
throw ctx.error("POLICY_DENIED", "Only the owner can manage invoices.");
|
|
16
|
+
}
|
|
17
|
+
const status = args.status.trim();
|
|
18
|
+
if (!STATUSES.has(status)) {
|
|
19
|
+
throw ctx.error("INVALID_ARGS", "Status must be draft, sent, paid, or overdue.");
|
|
20
|
+
}
|
|
21
|
+
const invoice = await ctx.db.get("Invoice", args.id);
|
|
22
|
+
if (!invoice) throw ctx.error("NOT_FOUND", "Invoice not found.");
|
|
23
|
+
|
|
24
|
+
await ctx.db.unsafe.update("Invoice", args.id, { status });
|
|
25
|
+
return { ok: true };
|
|
26
|
+
},
|
|
27
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { mutation, v } from "@pylonsync/functions";
|
|
2
|
+
import { emailMatchesOwner } from "../lib/owner";
|
|
3
|
+
|
|
4
|
+
// setProjectFlags — owner-only. A quick toggle for the two flags the owner flips
|
|
5
|
+
// most: `selected` (feature it on the homepage) and `published` (show it on the
|
|
6
|
+
// public site at all). Separate from the full upsertProject so the dashboard can
|
|
7
|
+
// flip a switch without round-tripping the whole form. Pass only the flag(s) you
|
|
8
|
+
// want to change.
|
|
9
|
+
export default mutation<
|
|
10
|
+
{ id: string; selected?: boolean; published?: boolean },
|
|
11
|
+
{ ok: boolean }
|
|
12
|
+
>({
|
|
13
|
+
auth: "user",
|
|
14
|
+
args: {
|
|
15
|
+
id: v.id("Project"),
|
|
16
|
+
selected: v.optional(v.boolean()),
|
|
17
|
+
published: v.optional(v.boolean()),
|
|
18
|
+
},
|
|
19
|
+
async handler(ctx, args) {
|
|
20
|
+
const me = await ctx.db.get("User", ctx.auth.userId);
|
|
21
|
+
if (!emailMatchesOwner(me?.email as string | undefined, ctx.env.PYLON_OWNER_EMAIL)) {
|
|
22
|
+
throw ctx.error("POLICY_DENIED", "Only the owner can manage projects.");
|
|
23
|
+
}
|
|
24
|
+
const project = await ctx.db.get("Project", args.id);
|
|
25
|
+
if (!project) throw ctx.error("NOT_FOUND", "Project not found.");
|
|
26
|
+
|
|
27
|
+
const patch: Record<string, boolean> = {};
|
|
28
|
+
if (typeof args.selected === "boolean") patch.selected = args.selected;
|
|
29
|
+
if (typeof args.published === "boolean") patch.published = args.published;
|
|
30
|
+
if (Object.keys(patch).length > 0) {
|
|
31
|
+
await ctx.db.unsafe.update("Project", args.id, patch);
|
|
32
|
+
}
|
|
33
|
+
return { ok: true };
|
|
34
|
+
},
|
|
35
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { mutation, v } from "@pylonsync/functions";
|
|
2
|
+
import { emailMatchesOwner } from "../lib/owner";
|
|
3
|
+
import type { ClientRow } from "../lib/agency";
|
|
4
|
+
|
|
5
|
+
// upsertClient — owner-only. Create a CRM contact or edit one (pass `id`). When
|
|
6
|
+
// a client's name or company changes, any invoice that referenced it keeps its
|
|
7
|
+
// denormalized `clientName` from when it was issued — invoices are a financial
|
|
8
|
+
// record and shouldn't silently rewrite themselves; the link by `clientId`
|
|
9
|
+
// still resolves for navigation.
|
|
10
|
+
type Args = {
|
|
11
|
+
id?: string;
|
|
12
|
+
name: string;
|
|
13
|
+
company?: string;
|
|
14
|
+
email?: string;
|
|
15
|
+
phone?: string;
|
|
16
|
+
status?: string;
|
|
17
|
+
notes?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
21
|
+
const STATUSES = new Set(["prospect", "active", "past"]);
|
|
22
|
+
const clip = (s: string | undefined, max: number): string | null =>
|
|
23
|
+
s != null && s.trim().length > 0 ? s.trim().slice(0, max) : null;
|
|
24
|
+
|
|
25
|
+
export default mutation<Args, { ok: boolean; id: string }>({
|
|
26
|
+
auth: "user",
|
|
27
|
+
args: {
|
|
28
|
+
id: v.optional(v.string()),
|
|
29
|
+
name: v.string(),
|
|
30
|
+
company: v.optional(v.string()),
|
|
31
|
+
email: v.optional(v.string()),
|
|
32
|
+
phone: v.optional(v.string()),
|
|
33
|
+
status: v.optional(v.string()),
|
|
34
|
+
notes: v.optional(v.string()),
|
|
35
|
+
},
|
|
36
|
+
async handler(ctx, args) {
|
|
37
|
+
const me = await ctx.db.get("User", ctx.auth.userId);
|
|
38
|
+
if (!emailMatchesOwner(me?.email as string | undefined, ctx.env.PYLON_OWNER_EMAIL)) {
|
|
39
|
+
throw ctx.error("POLICY_DENIED", "Only the owner can manage clients.");
|
|
40
|
+
}
|
|
41
|
+
const name = args.name.trim();
|
|
42
|
+
if (name.length < 1 || name.length > 120) {
|
|
43
|
+
throw ctx.error("INVALID_ARGS", "A client name is required (up to 120 chars).");
|
|
44
|
+
}
|
|
45
|
+
const email = clip(args.email, 254);
|
|
46
|
+
if (email && !EMAIL_RE.test(email)) {
|
|
47
|
+
throw ctx.error("INVALID_ARGS", "Enter a valid email address.");
|
|
48
|
+
}
|
|
49
|
+
const status = STATUSES.has((args.status ?? "").trim()) ? args.status!.trim() : "prospect";
|
|
50
|
+
|
|
51
|
+
const patch = {
|
|
52
|
+
name,
|
|
53
|
+
company: clip(args.company, 160),
|
|
54
|
+
email,
|
|
55
|
+
phone: clip(args.phone, 40),
|
|
56
|
+
status,
|
|
57
|
+
notes: clip(args.notes, 2000),
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
if (args.id) {
|
|
61
|
+
const existing = (await ctx.db.get("Client", args.id)) as ClientRow | null;
|
|
62
|
+
if (!existing) throw ctx.error("NOT_FOUND", "Client not found.");
|
|
63
|
+
await ctx.db.unsafe.update("Client", args.id, patch);
|
|
64
|
+
return { ok: true, id: args.id };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const id = await ctx.db.unsafe.insert("Client", {
|
|
68
|
+
...patch,
|
|
69
|
+
createdAt: new Date().toISOString(),
|
|
70
|
+
});
|
|
71
|
+
return { ok: true, id };
|
|
72
|
+
},
|
|
73
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { mutation, v } from "@pylonsync/functions";
|
|
2
|
+
import { emailMatchesOwner } from "../lib/owner";
|
|
3
|
+
import { lineItemsTotal, type ClientRow, type InvoiceLineItem, type ProjectRow } from "../lib/agency";
|
|
4
|
+
|
|
5
|
+
// upsertInvoice — owner-only. Create a bill or edit one (pass `id`). The client
|
|
6
|
+
// is referenced by id and must exist; we denormalize its name onto the invoice
|
|
7
|
+
// (`clientName`) so the list renders without a join, and so the bill keeps the
|
|
8
|
+
// name it was issued under even if the contact is renamed later. An optional
|
|
9
|
+
// project link works the same way (`projectTitle`).
|
|
10
|
+
//
|
|
11
|
+
// `lineItems` is the source of truth for the amount: when present we store it
|
|
12
|
+
// (JSON) and compute `amountCents = Σ quantity × unitCents` server-side, so the
|
|
13
|
+
// total can never disagree with the breakdown the client sent. When omitted we
|
|
14
|
+
// fall back to the explicit `amountCents` (a one-line bill). Amounts are cents.
|
|
15
|
+
type Args = {
|
|
16
|
+
id?: string;
|
|
17
|
+
number: string;
|
|
18
|
+
clientId: string;
|
|
19
|
+
projectId?: string;
|
|
20
|
+
lineItems?: InvoiceLineItem[];
|
|
21
|
+
amountCents: number;
|
|
22
|
+
status?: string;
|
|
23
|
+
issuedAt?: string;
|
|
24
|
+
dueAt?: string;
|
|
25
|
+
notes?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const STATUSES = new Set(["draft", "sent", "paid", "overdue"]);
|
|
29
|
+
const clip = (s: string | undefined, max: number): string | null =>
|
|
30
|
+
s != null && s.trim().length > 0 ? s.trim().slice(0, max) : null;
|
|
31
|
+
|
|
32
|
+
export default mutation<Args, { ok: boolean; id: string }>({
|
|
33
|
+
auth: "user",
|
|
34
|
+
args: {
|
|
35
|
+
id: v.optional(v.string()),
|
|
36
|
+
number: v.string(),
|
|
37
|
+
clientId: v.string(),
|
|
38
|
+
projectId: v.optional(v.string()),
|
|
39
|
+
lineItems: v.optional(
|
|
40
|
+
v.array(
|
|
41
|
+
v.object({ description: v.string(), quantity: v.number(), unitCents: v.int() }),
|
|
42
|
+
),
|
|
43
|
+
),
|
|
44
|
+
amountCents: v.int(),
|
|
45
|
+
status: v.optional(v.string()),
|
|
46
|
+
issuedAt: v.optional(v.string()),
|
|
47
|
+
dueAt: v.optional(v.string()),
|
|
48
|
+
notes: v.optional(v.string()),
|
|
49
|
+
},
|
|
50
|
+
async handler(ctx, args) {
|
|
51
|
+
const me = await ctx.db.get("User", ctx.auth.userId);
|
|
52
|
+
if (!emailMatchesOwner(me?.email as string | undefined, ctx.env.PYLON_OWNER_EMAIL)) {
|
|
53
|
+
throw ctx.error("POLICY_DENIED", "Only the owner can manage invoices.");
|
|
54
|
+
}
|
|
55
|
+
const number = args.number.trim();
|
|
56
|
+
if (number.length < 1 || number.length > 40) {
|
|
57
|
+
throw ctx.error("INVALID_ARGS", "An invoice number is required.");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const client = (await ctx.db.get("Client", args.clientId)) as ClientRow | null;
|
|
61
|
+
if (!client) throw ctx.error("INVALID_ARGS", "Pick a client for this invoice.");
|
|
62
|
+
|
|
63
|
+
let projectId: string | null = null;
|
|
64
|
+
let projectTitle: string | null = null;
|
|
65
|
+
if (args.projectId) {
|
|
66
|
+
const project = (await ctx.db.get("Project", args.projectId)) as ProjectRow | null;
|
|
67
|
+
if (project) {
|
|
68
|
+
projectId = project.id;
|
|
69
|
+
projectTitle = project.title;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Normalize line items; when present they define the total.
|
|
74
|
+
const items: InvoiceLineItem[] = (args.lineItems ?? [])
|
|
75
|
+
.map((it) => ({
|
|
76
|
+
description: (it.description ?? "").trim().slice(0, 300),
|
|
77
|
+
quantity: Math.max(0, Number(it.quantity) || 0),
|
|
78
|
+
unitCents: Math.max(0, Math.trunc(Number(it.unitCents) || 0)),
|
|
79
|
+
}))
|
|
80
|
+
.filter((it) => it.description.length > 0 || it.unitCents > 0);
|
|
81
|
+
|
|
82
|
+
const amountCents =
|
|
83
|
+
items.length > 0 ? lineItemsTotal(items) : Math.max(0, Math.trunc(args.amountCents || 0));
|
|
84
|
+
const status = STATUSES.has((args.status ?? "").trim()) ? args.status!.trim() : "draft";
|
|
85
|
+
|
|
86
|
+
const patch = {
|
|
87
|
+
number,
|
|
88
|
+
clientId: client.id,
|
|
89
|
+
clientName: client.name,
|
|
90
|
+
projectId,
|
|
91
|
+
projectTitle,
|
|
92
|
+
lineItems: items.length > 0 ? JSON.stringify(items) : null,
|
|
93
|
+
amountCents,
|
|
94
|
+
status,
|
|
95
|
+
issuedAt: clip(args.issuedAt, 20),
|
|
96
|
+
dueAt: clip(args.dueAt, 20),
|
|
97
|
+
notes: clip(args.notes, 2000),
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
if (args.id) {
|
|
101
|
+
const existing = await ctx.db.get("Invoice", args.id);
|
|
102
|
+
if (!existing) throw ctx.error("NOT_FOUND", "Invoice not found.");
|
|
103
|
+
await ctx.db.unsafe.update("Invoice", args.id, patch);
|
|
104
|
+
return { ok: true, id: args.id };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const id = await ctx.db.unsafe.insert("Invoice", {
|
|
108
|
+
...patch,
|
|
109
|
+
createdAt: new Date().toISOString(),
|
|
110
|
+
});
|
|
111
|
+
return { ok: true, id };
|
|
112
|
+
},
|
|
113
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { mutation, v } from "@pylonsync/functions";
|
|
2
|
+
import { emailMatchesOwner } from "../lib/owner";
|
|
3
|
+
import { slugify, type ProjectRow } from "../lib/agency";
|
|
4
|
+
|
|
5
|
+
// upsertProject — owner-only. Create a new case study or edit an existing one
|
|
6
|
+
// (pass `id` to update). Projects are public-read, so the change shows up on the
|
|
7
|
+
// dashboard's live `db.useQuery("Project")` immediately, and on the marketing
|
|
8
|
+
// site on its next render. The slug is derived from the title (or an explicit
|
|
9
|
+
// slug) and made unique — a unique index backs it, so we resolve collisions
|
|
10
|
+
// here rather than letting the insert fail.
|
|
11
|
+
type Args = {
|
|
12
|
+
id?: string;
|
|
13
|
+
title: string;
|
|
14
|
+
slug?: string;
|
|
15
|
+
client?: string;
|
|
16
|
+
summary?: string;
|
|
17
|
+
year?: string;
|
|
18
|
+
tags?: string;
|
|
19
|
+
selected?: boolean;
|
|
20
|
+
published?: boolean;
|
|
21
|
+
order?: number;
|
|
22
|
+
challenge?: string;
|
|
23
|
+
approach?: string;
|
|
24
|
+
outcome?: string;
|
|
25
|
+
liveUrl?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const clip = (s: string | undefined, max: number): string | null =>
|
|
29
|
+
s != null && s.trim().length > 0 ? s.trim().slice(0, max) : null;
|
|
30
|
+
|
|
31
|
+
export default mutation<Args, { ok: boolean; id: string; slug: string }>({
|
|
32
|
+
auth: "user",
|
|
33
|
+
args: {
|
|
34
|
+
id: v.optional(v.string()),
|
|
35
|
+
title: v.string(),
|
|
36
|
+
slug: v.optional(v.string()),
|
|
37
|
+
client: v.optional(v.string()),
|
|
38
|
+
summary: v.optional(v.string()),
|
|
39
|
+
year: v.optional(v.string()),
|
|
40
|
+
tags: v.optional(v.string()),
|
|
41
|
+
selected: v.optional(v.boolean()),
|
|
42
|
+
published: v.optional(v.boolean()),
|
|
43
|
+
order: v.optional(v.int()),
|
|
44
|
+
challenge: v.optional(v.string()),
|
|
45
|
+
approach: v.optional(v.string()),
|
|
46
|
+
outcome: v.optional(v.string()),
|
|
47
|
+
liveUrl: v.optional(v.string()),
|
|
48
|
+
},
|
|
49
|
+
async handler(ctx, args) {
|
|
50
|
+
const me = await ctx.db.get("User", ctx.auth.userId);
|
|
51
|
+
if (!emailMatchesOwner(me?.email as string | undefined, ctx.env.PYLON_OWNER_EMAIL)) {
|
|
52
|
+
throw ctx.error("POLICY_DENIED", "Only the owner can manage projects.");
|
|
53
|
+
}
|
|
54
|
+
const title = args.title.trim();
|
|
55
|
+
if (title.length < 1 || title.length > 120) {
|
|
56
|
+
throw ctx.error("INVALID_ARGS", "A project title is required (up to 120 chars).");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
await ctx.db.advisoryLock("agency_projects");
|
|
60
|
+
const all = (await ctx.db.unsafe.list("Project")) as unknown as ProjectRow[];
|
|
61
|
+
|
|
62
|
+
// Resolve a unique slug (skip the row being edited).
|
|
63
|
+
const base = slugify(args.slug || title) || "project";
|
|
64
|
+
let slug = base;
|
|
65
|
+
let n = 2;
|
|
66
|
+
while (all.some((p) => p.slug === slug && p.id !== args.id)) slug = `${base}-${n++}`;
|
|
67
|
+
|
|
68
|
+
const patch = {
|
|
69
|
+
title,
|
|
70
|
+
slug,
|
|
71
|
+
client: clip(args.client, 120) ?? "",
|
|
72
|
+
summary: clip(args.summary, 300) ?? "",
|
|
73
|
+
year: clip(args.year, 12),
|
|
74
|
+
tags: clip(args.tags, 200),
|
|
75
|
+
selected: args.selected === true,
|
|
76
|
+
published: args.published !== false, // default published
|
|
77
|
+
order: Math.trunc(args.order ?? 0),
|
|
78
|
+
challenge: clip(args.challenge, 2000),
|
|
79
|
+
approach: clip(args.approach, 2000),
|
|
80
|
+
outcome: clip(args.outcome, 2000),
|
|
81
|
+
liveUrl: clip(args.liveUrl, 400),
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
if (args.id) {
|
|
85
|
+
const existing = all.find((p) => p.id === args.id);
|
|
86
|
+
if (!existing) throw ctx.error("NOT_FOUND", "Project not found.");
|
|
87
|
+
await ctx.db.unsafe.update("Project", args.id, patch);
|
|
88
|
+
return { ok: true, id: args.id, slug };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const id = await ctx.db.unsafe.insert("Project", {
|
|
92
|
+
...patch,
|
|
93
|
+
createdAt: new Date().toISOString(),
|
|
94
|
+
});
|
|
95
|
+
return { ok: true, id, slug };
|
|
96
|
+
},
|
|
97
|
+
});
|