Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions app/api/brand-deals/draft/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { getBrandDeals, invalidateBrandDealsCache } from '@/lib/brand-deals';

export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';

const ID = z.string().regex(/^r-?\d+$/);

async function gsendBin(): Promise<string> {
const os = await import('node:os');
return process.env.GSEND_BIN ?? `${os.homedir()}/bin/gsend`;
}

/** Read a pending draft's current body (for the GladOS edit flow). */
export async function GET(req: Request) {
const id = new URL(req.url).searchParams.get('id') ?? '';
if (!ID.safeParse(id).success) {
return NextResponse.json({ ok: false, error: 'bad id' }, { status: 400 });
}
const { execFile } = await import('node:child_process');
const bin = await gsendBin();
const result = await new Promise<{ ok: boolean; out: string }>((resolve) => {
execFile(bin, ['draft-get', id], { timeout: 30_000, maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
resolve({ ok: !err, out: (err ? stderr || String(err) : stdout).trim() });
});
});
// Soft-fail like the connectors: a missing binary or vanished draft is an
// honest {ok:false}, not a 5xx.
if (!result.ok) return NextResponse.json({ ok: false, error: result.out });
try {
return NextResponse.json({ ok: true, draft: JSON.parse(result.out) });
} catch {
return NextResponse.json({ ok: false, error: 'unparseable draft' });
}
}

/** Update a pending draft's body. gsend locks recipient/subject/thread to
* the existing draft in code — an edit can never redirect the email. */
export async function POST(req: Request) {
let body: { dealId: string; text: string };
try {
body = z.object({ dealId: ID, text: z.string().min(1).max(20_000) }).parse(await req.json());
} catch (e) {
return NextResponse.json({ ok: false, error: String(e) }, { status: 400 });
}
const { execFile } = await import('node:child_process');
const bin = await gsendBin();
const result = await new Promise<{ ok: boolean; out: string }>((resolve) => {
const child = execFile(bin, ['draft-update', body.dealId], { timeout: 60_000 }, (err, stdout, stderr) => {
resolve({ ok: !err, out: (err ? stderr || String(err) : stdout).trim() });
});
child.stdin?.write(body.text);
child.stdin?.end();
});
invalidateBrandDealsCache();
if (!result.ok) return NextResponse.json({ ok: false, error: result.out }, { status: 502 });
return NextResponse.json({ ok: true, snapshot: await getBrandDeals() });
}
38 changes: 38 additions & 0 deletions app/api/brand-deals/override/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { getDb } from '@/lib/data';
import { BrandDealOutcomeSchema } from '@/lib/schemas';
import { getBrandDeals, invalidateBrandDealsCache } from '@/lib/brand-deals';

export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';

const Body = z.object({
dealId: z.string().min(1),
outcome: BrandDealOutcomeSchema.optional(),
amountUsd: z.number().int().positive().nullable().optional(),
});

/** Set a deal's outcome (won/lost/open) and/or its quoted amount. GladOS
* tracking overlay only — never touches bdpilot state, never emails. */
export async function POST(req: Request) {
let body: z.infer<typeof Body>;
try {
body = Body.parse(await req.json());
} catch (e) {
return NextResponse.json({ ok: false, error: String(e) }, { status: 400 });
}
if (body.outcome === undefined && body.amountUsd === undefined) {
return NextResponse.json({ ok: false, error: 'nothing to set' }, { status: 400 });
}
const db = getDb();
const existing = db.brandDeals.get(body.dealId);
db.brandDeals.upsert({
dealId: body.dealId,
outcome: body.outcome ?? existing?.outcome ?? 'open',
amountUsd: body.amountUsd !== undefined ? body.amountUsd : (existing?.amountUsd ?? null),
updatedAt: new Date().toISOString(),
});
invalidateBrandDealsCache();
return NextResponse.json({ ok: true, snapshot: await getBrandDeals() });
}
9 changes: 9 additions & 0 deletions app/api/brand-deals/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { NextResponse } from 'next/server';
import { getBrandDeals } from '@/lib/brand-deals';

export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';

export async function GET() {
return NextResponse.json(await getBrandDeals());
}
29 changes: 29 additions & 0 deletions app/api/brand-deals/send/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { getBrandDeals, invalidateBrandDealsCache } from '@/lib/brand-deals';

export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';

/** Approve a pending draft from GladOS: same verified gsend path as the
* Telegram tap. Gmail deletes the draft on send, so a send is atomic —
* a later Telegram tap on the same deal fails harmlessly. */
export async function POST(req: Request) {
let dealId: string;
try {
({ dealId } = z.object({ dealId: z.string().regex(/^r-?\d+$/) }).parse(await req.json()));
} catch (e) {
return NextResponse.json({ ok: false, error: String(e) }, { status: 400 });
}
const { execFile } = await import('node:child_process');
const os = await import('node:os');
const gsend = process.env.GSEND_BIN ?? `${os.homedir()}/bin/gsend`;
const result = await new Promise<{ ok: boolean; out: string }>((resolve) => {
execFile(gsend, ['send', dealId], { timeout: 60_000 }, (err, stdout, stderr) => {
resolve({ ok: !err, out: (err ? stderr || String(err) : stdout).trim() });
});
});
invalidateBrandDealsCache();
if (!result.ok) return NextResponse.json({ ok: false, error: result.out }, { status: 502 });
return NextResponse.json({ ok: true, detail: result.out, snapshot: await getBrandDeals() });
}
12 changes: 12 additions & 0 deletions app/brand-deals/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { getBrandDeals } from '@/lib/brand-deals';
import { DealBoard } from '@/components/brand-deals/DealBoard';

export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';

// GladOS Futurism (Zentra) layout: the title row lives inside the floating
// slab, so this page skips the shared PageHeader deliberately.
export default async function BrandDealsPage() {
const snapshot = await getBrandDeals();
return <DealBoard initial={snapshot} />;
}
Loading
Loading