Skip to content
Draft
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
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

[![CI](https://github.com/ColinLi98/promotion-agent/actions/workflows/ci.yml/badge.svg)](https://github.com/ColinLi98/promotion-agent/actions/workflows/ci.yml)

Demo: [https://promotion-agent-demo.vercel.app](https://promotion-agent-demo.vercel.app)

Backend-first MVP scaffold for the PRD in [Promotion_Agent_PRD_v0.9.docx](./Promotion_Agent_PRD_v0.9.docx).

## What is implemented
Expand Down Expand Up @@ -50,6 +52,25 @@ pnpm start

Server starts on `http://localhost:3000`.

## Demo Mode

For a stable stakeholder demo with virtual data and isolated in-memory state:

Hosted demo:

- https://promotion-agent-demo.vercel.app

```bash
pnpm start:demo
```

Demo mode starts on `http://localhost:3001` and:

- uses a richer synthetic product dataset
- bootstraps measurement, settlement, queue, audit, and risk activity automatically
- keeps CRM focused on demo data instead of real discovery output
- ignores PostgreSQL / Redis / billing adapter runtime state so the demo stays deterministic

By default the app uses in-memory persistence and in-memory hot state. If `DATABASE_URL` is set, startup switches to PostgreSQL automatically. If `REDIS_URL` is set, idempotency keys and opportunity cache switch to Redis.

Hot-state keys are namespaced and versioned:
Expand Down
1 change: 1 addition & 0 deletions api/[...path].ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "./_handler.js";
38 changes: 38 additions & 0 deletions api/_handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { createConfiguredStore } from "../src/factory.js";
import { buildServer } from "../src/server.js";

type CachedApp = {
app: ReturnType<typeof buildServer>;
};

declare global {
var __promotionAgentDemoApp__: Promise<CachedApp> | undefined;
}

const getCachedApp = async () => {
if (!globalThis.__promotionAgentDemoApp__) {
globalThis.__promotionAgentDemoApp__ = (async () => {
const { store, appMode } = await createConfiguredStore();
const app = buildServer(store, { appMode });
await app.ready();
return { app };
})();
}

return globalThis.__promotionAgentDemoApp__;
};

const stripApiPrefix = (url: string | undefined) => {
if (!url) {
return "/";
}

const normalized = url.replace(/^\/api(?=\/|$)/, "");
return normalized === "" ? "/" : normalized;
};

export default async function handler(req: { url?: string }, res: unknown) {
const { app } = await getCachedApp();
req.url = stripApiPrefix(req.url);
app.server.emit("request", req, res);
}
1 change: 1 addition & 0 deletions api/agent-leads/[...path].ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "../_handler.js";
1 change: 1 addition & 0 deletions api/agents/[...path].ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "../_handler.js";
1 change: 1 addition & 0 deletions api/appeals/[...path].ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "../_handler.js";
1 change: 1 addition & 0 deletions api/campaigns/[...path].ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "../_handler.js";
1 change: 1 addition & 0 deletions api/discovery/[...path].ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "../_handler.js";
1 change: 1 addition & 0 deletions api/evidence/[...path].ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "../_handler.js";
1 change: 1 addition & 0 deletions api/measurements/[...path].ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "../_handler.js";
1 change: 1 addition & 0 deletions api/risk/[...path].ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "../_handler.js";
1 change: 1 addition & 0 deletions api/settlements/[...path].ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "../_handler.js";
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "tsx src/index.ts",
"start:demo": "APP_MODE=demo PORT=3001 tsx src/index.ts",
"typecheck": "tsc --noEmit",
"db:init": "tsx scripts/db-init.ts",
"db:embedded": "tsx scripts/start-embedded-postgres.ts",
Expand Down
1 change: 1 addition & 0 deletions public/agent-detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ <h3 class="panel-title">Verification 历史</h3>
</div>
</div>

<script src="/app-config.js"></script>
<script type="module" src="/agent-detail.js"></script>
</body>
</html>
1 change: 1 addition & 0 deletions public/agents.html
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ <h3 class="panel-title">Agent Leads</h3>
</div>
</div>

<script src="/app-config.js"></script>
<script type="module" src="/agents.js"></script>
</body>
</html>
6 changes: 4 additions & 2 deletions public/agents.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ const leadDetailPanel = document.querySelector("#leadDetailPanel");
const sourceForm = document.querySelector("#sourceForm");
const leadFilters = document.querySelector("#leadFilters");
const sourceFeedback = document.querySelector("#sourceFeedback");
const appMode = window.__PROMOTION_AGENT_CONFIG__?.mode ?? "default";
const defaultDataOriginFilter = appMode === "demo" ? "" : "discovered";

const state = {
selectedLeadId: null,
Expand Down Expand Up @@ -208,7 +210,7 @@ const load = async () => {
api.get(`/agent-leads?${query.toString()}`),
]);
if (leads.length === 0) {
if ((query.get("dataOrigin") ?? "discovered") === "discovered") {
if ((query.get("dataOrigin") ?? defaultDataOriginFilter) === "discovered") {
sourceFeedback.textContent = "当前还没有真实 discovered leads。先运行上方真实 source crawl,或把 Data Origin 切回 seed 查看历史样例。";
} else {
sourceFeedback.textContent = "当前筛选条件下没有匹配结果。";
Expand Down Expand Up @@ -243,7 +245,7 @@ leadFilters.addEventListener("submit", async (event) => {
await load();
});

leadFilters.querySelector('[name="dataOrigin"]').value = "discovered";
leadFilters.querySelector('[name="dataOrigin"]').value = defaultDataOriginFilter;

document.addEventListener("click", async (event) => {
const target = event.target;
Expand Down
1 change: 1 addition & 0 deletions public/audit.html
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ <h3 class="panel-title">事件列表</h3>
</div>
</div>

<script src="/app-config.js"></script>
<script type="module" src="/audit.js"></script>
</body>
</html>
1 change: 1 addition & 0 deletions public/dlq.html
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ <h3 class="panel-title">DLQ 列表</h3>
</div>
</div>

<script src="/app-config.js"></script>
<script type="module" src="/dlq.js"></script>
</body>
</html>
1 change: 1 addition & 0 deletions public/evidence.html
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ <h3 class="panel-title">证据资产</h3>
</main>
</div>
</div>
<script src="/app-config.js"></script>
<script type="module" src="/evidence.js"></script>
</body>
</html>
1 change: 1 addition & 0 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,7 @@ <h2 class="panel-title">Shortlist 模拟器</h2>
</main>
</div>

<script src="/app-config.js"></script>
<script type="module" src="/app.js"></script>
</body>
</html>
1 change: 1 addition & 0 deletions public/measurement.html
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ <h3 class="panel-title">账单草案</h3>
</div>
</div>

<script src="/app-config.js"></script>
<script type="module" src="/measurement.js"></script>
</body>
</html>
1 change: 1 addition & 0 deletions public/risk.html
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ <h3 class="panel-title">申诉流</h3>
</main>
</div>
</div>
<script src="/app-config.js"></script>
<script type="module" src="/risk.js"></script>
</body>
</html>
178 changes: 178 additions & 0 deletions src/demo-scenario.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import type { EventReceipt } from "./domain.js";
import type { PromotionAgentStore } from "./store.js";

const demoReceipt = (receipt: EventReceipt): EventReceipt => receipt;

export const bootstrapDemoScenario = async (store: PromotionAgentStore) => {
const existingSettlements = await store.listSettlements();
if (existingSettlements.length > 0) {
return;
}

const receiptsToSettle = [
demoReceipt({
receiptId: "rcpt_demo_hubflow_shown",
intentId: "int_demo_hubflow_01",
offerId: "offer_demo_hubflow",
campaignId: "cmp_demo_hubflow",
partnerId: "partner_demo_northstar",
eventType: "shown",
occurredAt: "2026-03-11T09:00:00.000Z",
signature: "sig_demo",
}),
demoReceipt({
receiptId: "rcpt_demo_hubflow_detail",
intentId: "int_demo_hubflow_01",
offerId: "offer_demo_hubflow",
campaignId: "cmp_demo_hubflow",
partnerId: "partner_demo_northstar",
eventType: "detail_view",
occurredAt: "2026-03-11T09:01:00.000Z",
signature: "sig_demo",
}),
demoReceipt({
receiptId: "rcpt_demo_hubflow_handoff",
intentId: "int_demo_hubflow_01",
offerId: "offer_demo_hubflow",
campaignId: "cmp_demo_hubflow",
partnerId: "partner_demo_northstar",
eventType: "handoff",
occurredAt: "2026-03-11T09:02:00.000Z",
signature: "sig_demo",
}),
demoReceipt({
receiptId: "rcpt_demo_hubflow_shortlisted",
intentId: "int_demo_hubflow_01",
offerId: "offer_demo_hubflow",
campaignId: "cmp_demo_hubflow",
partnerId: "partner_demo_northstar",
eventType: "shortlisted",
occurredAt: "2026-03-11T09:03:00.000Z",
signature: "sig_demo",
}),
demoReceipt({
receiptId: "rcpt_demo_northstar_shown",
intentId: "int_demo_northstar_02",
offerId: "offer_demo_northstar",
campaignId: "cmp_demo_northstar",
partnerId: "partner_demo_vector",
eventType: "shown",
occurredAt: "2026-03-11T10:00:00.000Z",
signature: "sig_demo",
}),
demoReceipt({
receiptId: "rcpt_demo_northstar_detail",
intentId: "int_demo_northstar_02",
offerId: "offer_demo_northstar",
campaignId: "cmp_demo_northstar",
partnerId: "partner_demo_vector",
eventType: "detail_view",
occurredAt: "2026-03-11T10:01:00.000Z",
signature: "sig_demo",
}),
demoReceipt({
receiptId: "rcpt_demo_northstar_shortlisted",
intentId: "int_demo_northstar_02",
offerId: "offer_demo_northstar",
campaignId: "cmp_demo_northstar",
partnerId: "partner_demo_vector",
eventType: "shortlisted",
occurredAt: "2026-03-11T10:02:00.000Z",
signature: "sig_demo",
}),
demoReceipt({
receiptId: "rcpt_demo_signalstack_shown",
intentId: "int_demo_signalstack_03",
offerId: "offer_demo_signalstack",
campaignId: "cmp_demo_signalstack",
partnerId: "partner_demo_summit",
eventType: "shown",
occurredAt: "2026-03-11T11:00:00.000Z",
signature: "sig_demo",
}),
demoReceipt({
receiptId: "rcpt_demo_signalstack_detail",
intentId: "int_demo_signalstack_03",
offerId: "offer_demo_signalstack",
campaignId: "cmp_demo_signalstack",
partnerId: "partner_demo_summit",
eventType: "detail_view",
occurredAt: "2026-03-11T11:01:00.000Z",
signature: "sig_demo",
}),
demoReceipt({
receiptId: "rcpt_demo_signalstack_handoff",
intentId: "int_demo_signalstack_03",
offerId: "offer_demo_signalstack",
campaignId: "cmp_demo_signalstack",
partnerId: "partner_demo_summit",
eventType: "handoff",
occurredAt: "2026-03-11T11:02:00.000Z",
signature: "sig_demo",
}),
demoReceipt({
receiptId: "rcpt_demo_signalstack_conversion",
intentId: "int_demo_signalstack_03",
offerId: "offer_demo_signalstack",
campaignId: "cmp_demo_signalstack",
partnerId: "partner_demo_summit",
eventType: "conversion",
occurredAt: "2026-03-11T11:03:00.000Z",
signature: "sig_demo",
}),
demoReceipt({
receiptId: "rcpt_demo_vector_shortlisted",
intentId: "int_demo_vector_04",
offerId: "offer_demo_vector",
campaignId: "cmp_demo_vector",
partnerId: "partner_demo_vector",
eventType: "shortlisted",
occurredAt: "2026-03-11T12:00:00.000Z",
signature: "sig_demo",
}),
];

for (const receipt of receiptsToSettle) {
await store.recordReceipt(receipt);
}

await store.processSettlementRetryQueue(20);

await store.recordReceipt(
demoReceipt({
receiptId: "rcpt_demo_queue_pending",
intentId: "int_demo_queue_05",
offerId: "offer_demo_hubflow",
campaignId: "cmp_demo_hubflow",
partnerId: "partner_demo_summit",
eventType: "shortlisted",
occurredAt: "2026-03-11T13:00:00.000Z",
signature: "sig_demo",
}),
);

const disputed = await store.recordReceipt(
demoReceipt({
receiptId: "rcpt_demo_disputed",
intentId: "int_demo_dispute_06",
offerId: "offer_demo_northstar",
campaignId: "cmp_demo_northstar",
partnerId: "partner_demo_northstar",
eventType: "shortlisted",
occurredAt: "2026-03-11T14:00:00.000Z",
signature: "sig_demo",
}),
);

if (disputed.settlement) {
await store.markSettlementDisputed(disputed.settlement.settlementId);
await store.createRiskCase({
entityType: "settlement",
entityId: disputed.settlement.settlementId,
reasonType: "policy_violation",
severity: "medium",
ownerId: "risk:irene",
note: "Disputed settlement created for demo queue handling.",
});
}
};
Loading