@mohasinac/appkit 4.38.0 → 4.39.0
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/dist/_internal/client/features/digital-code/CodeRevealPanel.d.ts +20 -1
- package/dist/_internal/client/features/digital-code/CodeRevealPanel.js +21 -1
- package/dist/_internal/client/features/digital-code/DigitalContentPoolManager.d.ts +40 -0
- package/dist/_internal/client/features/digital-code/DigitalContentPoolManager.js +67 -0
- package/dist/_internal/server/features/checkout/actions.js +18 -0
- package/dist/_internal/server/features/digital-code/download.d.ts +60 -0
- package/dist/_internal/server/features/digital-code/download.js +128 -0
- package/dist/_internal/server/features/digital-code/pool.d.ts +101 -0
- package/dist/_internal/server/features/digital-code/pool.js +208 -0
- package/dist/_internal/server/features/digital-code/upload.d.ts +51 -0
- package/dist/_internal/server/features/digital-code/upload.js +144 -0
- package/dist/_internal/server/jobs/core/cleanupRtdbEvents.js +40 -0
- package/dist/_internal/server/jobs/core/countersReconcile.js +153 -40
- package/dist/_internal/server/jobs/core/onCategoryWrite.js +33 -0
- package/dist/_internal/server/jobs/core/onProductWrite.js +93 -25
- package/dist/_internal/server/notifications/send-recorder.d.ts +79 -0
- package/dist/_internal/server/notifications/send-recorder.js +126 -0
- package/dist/client.d.ts +2 -0
- package/dist/client.js +6 -0
- package/dist/features/blog/components/BlogIndexPageView.js +10 -1
- package/dist/features/blog/repository/blog.repository.d.ts +14 -0
- package/dist/features/blog/repository/blog.repository.js +23 -1
- package/dist/features/categories/components/CategoryDetailPageView.js +1 -1
- package/dist/features/categories/components/CategoryTree.js +1 -1
- package/dist/features/categories/repository/categories.repository.d.ts +34 -2
- package/dist/features/categories/repository/categories.repository.js +31 -9
- package/dist/features/contact/email.js +36 -1
- package/dist/features/orders/repository/orders.repository.d.ts +21 -1
- package/dist/features/orders/repository/orders.repository.js +26 -9
- package/dist/features/products/repository/products.repository.d.ts +35 -0
- package/dist/features/products/repository/products.repository.js +116 -3
- package/dist/features/products/schemas/firestore.d.ts +37 -0
- package/dist/features/seller/actions/seller-actions.js +4 -13
- package/dist/features/seller/components/SellerProductShell.d.ts +7 -1
- package/dist/features/seller/components/SellerProductShell.js +11 -2
- package/dist/features/stores/api/[storeSlug]/reviews/route.d.ts +6 -1
- package/dist/features/stores/api/[storeSlug]/reviews/route.js +89 -30
- package/dist/features/stores/components/StoresIndexPageView.js +17 -1
- package/dist/features/tester/seed-data/authored/admin__users-trust.js +1 -1
- package/dist/features/tester/seed-data/authored/buying__offers.js +1 -1
- package/dist/features/tester/seed-data/authored/buying__user-dashboard-extras.js +6 -6
- package/dist/features/tester/utils/phases.js +78 -12
- package/dist/providers/db-firebase/rtdb-paths.d.ts +7 -0
- package/dist/providers/db-firebase/rtdb-paths.js +7 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +12 -0
- package/dist/ui/components/FilePickerButton.d.ts +32 -0
- package/dist/ui/components/FilePickerButton.js +23 -0
- package/dist/ui/index.d.ts +2 -0
- package/dist/ui/index.js +7 -0
- package/package.json +1 -1
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
+
/**
|
|
3
|
+
* What the buyer was actually delivered.
|
|
4
|
+
*
|
|
5
|
+
* A "digital code" listing delivers digital CONTENT — a redemption string is
|
|
6
|
+
* only its commonest shape. `contentKind` is optional and absent means `"code"`,
|
|
7
|
+
* so a delivery recorded before assets existed renders exactly as it always did.
|
|
8
|
+
*/
|
|
2
9
|
export interface RevealedCode {
|
|
3
|
-
code
|
|
10
|
+
contentKind?: "code" | "image" | "file";
|
|
11
|
+
/** Present only for `contentKind: "code"`. */
|
|
12
|
+
code?: string;
|
|
13
|
+
/**
|
|
14
|
+
* Present for the asset kinds. Points at the AUTHENTICATED download route,
|
|
15
|
+
* never at Storage and never at `/media/` — see the route's own header for
|
|
16
|
+
* why that distinction is the whole security model here.
|
|
17
|
+
*/
|
|
18
|
+
downloadUrl?: string;
|
|
19
|
+
fileName?: string;
|
|
20
|
+
contentType?: string;
|
|
21
|
+
/** Seller-authored redemption help, carried by the reveal response. */
|
|
22
|
+
redemptionInstructions?: string;
|
|
4
23
|
orderId: string;
|
|
5
24
|
claimedAt?: Date | string;
|
|
6
25
|
expiresAt?: Date | string;
|
|
@@ -36,5 +36,25 @@ export function CodeRevealPanel({ orderId, redemptionInstructions, fetchCode, })
|
|
|
36
36
|
setCopied(true);
|
|
37
37
|
setTimeout(() => setCopied(false), 2000);
|
|
38
38
|
}
|
|
39
|
-
return (_jsx(Div, { className: `border border-border bg-muted/40 ${__P.p4}`, rounded: "lg", children: _jsxs(Stack, { gap: "sm", children: [_jsx(Text, { weight: "medium", children:
|
|
39
|
+
return (_jsx(Div, { className: `border border-border bg-muted/40 ${__P.p4}`, rounded: "lg", children: _jsxs(Stack, { gap: "sm", children: [_jsx(Text, { weight: "medium", children: revealed && (revealed.contentKind ?? "code") !== "code"
|
|
40
|
+
? "Your Digital Content"
|
|
41
|
+
: "Your Digital Code" }), !revealed ? (_jsxs(Stack, { gap: "sm", children: [error && _jsx(Text, { className: "text-destructive", size: "sm", children: error }), _jsx(Button, { type: "button", variant: "primary", size: "lg", isLoading: pending, disabled: pending, onClick: handleReveal, className: "w-full", children: "Reveal Code" })] })) : (_jsxs(Stack, { gap: "sm", children: [(revealed.contentKind ?? "code") === "code" ? (_jsxs(Row, { textSize: "lg", className: "border border-border bg-background font-mono", padding: "inlineSm", align: "center", gap: "sm", rounded: "md", children: [_jsx(Span, { className: "flex-1 select-all", children: revealed.code }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: handleCopy, className: "shrink-0", children: copied ? "Copied!" : "Copy" })] })) : (
|
|
42
|
+
/*
|
|
43
|
+
* 🛑 A LINK, not an <img>/<MediaImage>. Rendering the asset inline
|
|
44
|
+
* would need a URL a browser can GET without our session
|
|
45
|
+
* semantics, and this one is a `private, no-store` authenticated
|
|
46
|
+
* download that always answers `Content-Disposition: attachment`.
|
|
47
|
+
* An <img src> pointed at it would show a broken image, and
|
|
48
|
+
* "fixing" that by moving the bytes somewhere renderable is
|
|
49
|
+
* precisely what publishes a paid good — see the download route's
|
|
50
|
+
* header.
|
|
51
|
+
*
|
|
52
|
+
* Not a <Button onClick={fetch}> either: a same-origin anchor lets
|
|
53
|
+
* the browser own the download, including resume and the file
|
|
54
|
+
* name, and never materialises the bytes in JS memory.
|
|
55
|
+
*/
|
|
56
|
+
_jsxs(Row, { className: "border border-border bg-background", padding: "inlineSm", align: "center", gap: "sm", rounded: "md", children: [_jsx(Span, { className: "flex-1 truncate", children: revealed.fileName ?? "Your download" }), _jsx(Button, { type: "button", variant: "primary", size: "sm", className: "shrink-0", onClick: () => {
|
|
57
|
+
if (revealed.downloadUrl)
|
|
58
|
+
window.location.assign(revealed.downloadUrl);
|
|
59
|
+
}, children: "Download" })] })), (redemptionInstructions ?? revealed.redemptionInstructions) && (_jsx(Text, { className: "text-muted-foreground", size: "sm", children: redemptionInstructions ?? revealed.redemptionInstructions }))] }))] }) }));
|
|
40
60
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
/**
|
|
3
|
+
* The seller's view of what a digital-code listing will actually deliver.
|
|
4
|
+
*
|
|
5
|
+
* 🛑 WHY THIS COMPONENT HAD TO EXIST FOR THE FEATURE TO WORK AT ALL. The pool
|
|
6
|
+
* at `products/{id}/codes` is what `claimDigitalCodeForOrder` hands a buyer, and
|
|
7
|
+
* until 2026-09-14 nothing in the product could write to it — its one route
|
|
8
|
+
* answered 501. So the pool was always empty, every purchase logged "code pool
|
|
9
|
+
* exhausted" and completed anyway, and the buyer's reveal panel 404'd. Shipping
|
|
10
|
+
* the routes without this surface would have left the same gap one layer up: a
|
|
11
|
+
* working API nobody can reach is Root Cause #37's shape.
|
|
12
|
+
*
|
|
13
|
+
* Edit mode only — a pool is keyed on a product id, which does not exist until
|
|
14
|
+
* the listing has been created once.
|
|
15
|
+
*/
|
|
16
|
+
export interface PoolEntry {
|
|
17
|
+
id: string;
|
|
18
|
+
contentKind: "code" | "image" | "file";
|
|
19
|
+
status: string;
|
|
20
|
+
fileName?: string;
|
|
21
|
+
orderId?: string;
|
|
22
|
+
claimedAt?: string;
|
|
23
|
+
createdAt?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface DigitalContentPoolManagerProps {
|
|
26
|
+
productId: string;
|
|
27
|
+
/**
|
|
28
|
+
* Injected so this component never hard-codes an API path — the same contract
|
|
29
|
+
* `CodeRevealPanel` uses. The consumer owns the transport.
|
|
30
|
+
*/
|
|
31
|
+
api: {
|
|
32
|
+
list: (productId: string) => Promise<PoolEntry[]>;
|
|
33
|
+
addCodes: (productId: string, codes: string[]) => Promise<unknown>;
|
|
34
|
+
addAsset: (productId: string, file: File) => Promise<unknown>;
|
|
35
|
+
remove: (productId: string, entryId: string) => Promise<unknown>;
|
|
36
|
+
};
|
|
37
|
+
/** Non-image files are staff-only to upload; hide the affordance otherwise. */
|
|
38
|
+
canUploadFiles?: boolean;
|
|
39
|
+
}
|
|
40
|
+
export declare function DigitalContentPoolManager({ productId, api, canUploadFiles, }: DigitalContentPoolManagerProps): React.JSX.Element;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useCallback, useEffect, useState } from "react";
|
|
4
|
+
import { normalizeError } from "../../../../errors/normalize";
|
|
5
|
+
import { Alert, Button, Div, Row, Span, Stack, Text } from "../../../../ui";
|
|
6
|
+
import { FilePickerButton } from "../../../../ui/components/FilePickerButton";
|
|
7
|
+
import { FieldTextarea } from "../../../../ui/forms/FieldTextarea";
|
|
8
|
+
const KIND_LABEL = {
|
|
9
|
+
code: "Code",
|
|
10
|
+
image: "QR / image",
|
|
11
|
+
file: "File",
|
|
12
|
+
};
|
|
13
|
+
export function DigitalContentPoolManager({ productId, api, canUploadFiles = false, }) {
|
|
14
|
+
const [entries, setEntries] = useState(null);
|
|
15
|
+
const [bulk, setBulk] = useState("");
|
|
16
|
+
const [busy, setBusy] = useState(false);
|
|
17
|
+
const [error, setError] = useState(null);
|
|
18
|
+
const refresh = useCallback(async () => {
|
|
19
|
+
try {
|
|
20
|
+
setEntries(await api.list(productId));
|
|
21
|
+
setError(null);
|
|
22
|
+
}
|
|
23
|
+
catch (e) {
|
|
24
|
+
/*
|
|
25
|
+
* Surface it. An empty list and a failed list look identical, and "this
|
|
26
|
+
* listing has no codes" is exactly the wrong thing to tell a seller whose
|
|
27
|
+
* pool is full — the same confusion that made the 501 invisible.
|
|
28
|
+
*/
|
|
29
|
+
setError(normalizeError(e).message || "Could not load the pool.");
|
|
30
|
+
setEntries([]);
|
|
31
|
+
}
|
|
32
|
+
}, [api, productId]);
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
void refresh();
|
|
35
|
+
}, [refresh]);
|
|
36
|
+
async function run(fn) {
|
|
37
|
+
setBusy(true);
|
|
38
|
+
setError(null);
|
|
39
|
+
try {
|
|
40
|
+
await fn();
|
|
41
|
+
await refresh();
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
setError(normalizeError(e).message || "That did not work.");
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
setBusy(false);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const available = (entries ?? []).filter((e) => e.status === "available").length;
|
|
51
|
+
const claimed = (entries ?? []).filter((e) => e.status === "claimed").length;
|
|
52
|
+
return (_jsxs(Stack, { gap: "md", children: [_jsx(Alert, { variant: "info", children: "Whatever you add here is what a buyer receives after paying. A listing with an empty pool can still be bought \u2014 and the buyer gets nothing \u2014 so add at least one entry before publishing." }), error && _jsx(Alert, { variant: "error", children: error }), _jsxs(Row, { gap: "md", align: "center", children: [_jsxs(Text, { size: "sm", children: [_jsx(Span, { weight: "semibold", children: available }), " available"] }), _jsxs(Text, { size: "sm", color: "muted", children: [_jsx(Span, { weight: "semibold", children: claimed }), " already delivered"] })] }), _jsx(FieldTextarea, { name: "bulkCodes", label: "Add codes", value: bulk, onChange: setBulk, rows: 4, placeholder: "ONE-CODE-PER-LINE\nANOTHER-CODE" }), _jsx(Text, { size: "xs", color: "muted", children: "One per line. Blank lines and duplicates within the paste are ignored." }), _jsxs(Row, { gap: "sm", children: [_jsx(Button, { type: "button", variant: "primary", size: "sm", isLoading: busy, disabled: busy || bulk.trim().length === 0, onClick: () => {
|
|
53
|
+
// De-duplicate the paste itself. Re-pasting a list is the obvious
|
|
54
|
+
// way to double a pool by accident, and a duplicated code is a
|
|
55
|
+
// second buyer receiving something already sold.
|
|
56
|
+
const codes = [
|
|
57
|
+
...new Set(bulk
|
|
58
|
+
.split("\n")
|
|
59
|
+
.map((l) => l.trim())
|
|
60
|
+
.filter(Boolean)),
|
|
61
|
+
];
|
|
62
|
+
void run(async () => {
|
|
63
|
+
await api.addCodes(productId, codes);
|
|
64
|
+
setBulk("");
|
|
65
|
+
});
|
|
66
|
+
}, children: "Add codes" }), _jsx(FilePickerButton, { variant: "secondary", size: "sm", disabled: busy, accept: canUploadFiles ? undefined : "image/*", onFile: (file) => void run(() => api.addAsset(productId, file)), children: canUploadFiles ? "Upload QR or file" : "Upload QR image" })] }), _jsx(Div, { children: entries === null ? (_jsx(Text, { size: "sm", color: "muted", children: "Loading\u2026" })) : entries.length === 0 ? (_jsx(Text, { size: "sm", color: "muted", children: "Nothing in the pool yet." })) : (_jsx(Stack, { gap: "xs", children: entries.map((e) => (_jsxs(Row, { gap: "sm", align: "center", justify: "between", children: [_jsxs(Text, { size: "sm", children: [KIND_LABEL[e.contentKind], e.fileName ? ` — ${e.fileName}` : "", _jsxs(Span, { color: "muted", children: [" \u00B7 ", e.status] })] }), e.status !== "claimed" && (_jsx(Button, { type: "button", variant: "ghost", size: "sm", disabled: busy, onClick: () => void run(() => api.remove(productId, e.id)), children: "Remove" }))] }, e.id))) })) })] }));
|
|
67
|
+
}
|
|
@@ -73,6 +73,24 @@ async function claimDigitalCodeForOrder(db, productId, orderId, userId, opts) {
|
|
|
73
73
|
});
|
|
74
74
|
claimed = true;
|
|
75
75
|
});
|
|
76
|
+
if (claimed) {
|
|
77
|
+
/*
|
|
78
|
+
* Re-derive both pool counters. Nothing used to do this: the claim flipped a
|
|
79
|
+
* code to `claimed` and left `digitalCode.codesAvailable` exactly as the
|
|
80
|
+
* seller had typed it, so the availability predicate — which reads that
|
|
81
|
+
* counter — could never notice a listing selling out. Counting beats
|
|
82
|
+
* decrementing here because the counter was never trustworthy to begin with
|
|
83
|
+
* (Root Cause #42).
|
|
84
|
+
*
|
|
85
|
+
* Deliberately awaited but non-fatal inside: a failed recount must not undo
|
|
86
|
+
* a claim the buyer has already paid for.
|
|
87
|
+
*/
|
|
88
|
+
const { recountPool } = await import("../digital-code/pool");
|
|
89
|
+
await recountPool(productId).catch((e) => serverLogger.error("claimDigitalCode: recount failed", {
|
|
90
|
+
error: normalizeError(e).message,
|
|
91
|
+
productId,
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
76
94
|
if (claimed && opts?.userEmail) {
|
|
77
95
|
const { sendDigitalCodeClaimedEmail } = await import("../../../../features/contact/server");
|
|
78
96
|
sendDigitalCodeClaimedEmail({
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streams the digital ASSET a buyer paid for.
|
|
3
|
+
*
|
|
4
|
+
* 🛑 WHY THIS ROUTE EXISTS AT ALL, rather than a `/media/{slug}` URL.
|
|
5
|
+
*
|
|
6
|
+
* Two independent facts make the ordinary media pipeline unusable for paid
|
|
7
|
+
* content, and either one alone would be enough:
|
|
8
|
+
*
|
|
9
|
+
* 1. `storage.rules` grants `allow read: if true` on **every object in the
|
|
10
|
+
* bucket**. The comment above it explains why that is acceptable — the
|
|
11
|
+
* rules are a safety net for a misconfigured client, all real access goes
|
|
12
|
+
* through the Admin SDK — but it means Storage itself protects nothing.
|
|
13
|
+
* 2. `GET /api/media/[...slug]` performs **no authentication whatsoever**. It
|
|
14
|
+
* is a bare exported handler with no session read, deliberately, because
|
|
15
|
+
* its job is to serve public product photography through a watermarker.
|
|
16
|
+
* It also resolves legacy multi-segment slugs as raw storage paths.
|
|
17
|
+
*
|
|
18
|
+
* Together: anything reachable at a `/media/` slug is world-readable, and this
|
|
19
|
+
* codebase's media filenames are content-derived and therefore guessable. A
|
|
20
|
+
* purchased QR living there would be a paid good at a predictable public URL.
|
|
21
|
+
*
|
|
22
|
+
* So the bytes live under a private prefix that mints no `mediaAssets` row (and
|
|
23
|
+
* therefore no slug), and are streamed only from here — after re-checking that
|
|
24
|
+
* the caller owns the order. That is the same shape as
|
|
25
|
+
* `GET /api/user/orders/[id]/invoice`, the one existing precedent in this
|
|
26
|
+
* codebase for a gated download.
|
|
27
|
+
*/
|
|
28
|
+
/** Order statuses at which delivered content may be downloaded. */
|
|
29
|
+
export declare const CODE_DOWNLOAD_STATUSES: Set<string>;
|
|
30
|
+
export interface CodeAssetResolution {
|
|
31
|
+
ok: boolean;
|
|
32
|
+
/** HTTP status to answer with when `ok` is false. */
|
|
33
|
+
status?: number;
|
|
34
|
+
message?: string;
|
|
35
|
+
buffer?: Buffer;
|
|
36
|
+
contentType?: string;
|
|
37
|
+
fileName?: string;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Resolve and read the asset for `orderId`, or explain why not.
|
|
41
|
+
*
|
|
42
|
+
* `viewerUid` is the authenticated caller. `isStaff` lets admin/moderator open a
|
|
43
|
+
* buyer's delivery for support — the same escape hatch the invoice route has.
|
|
44
|
+
*/
|
|
45
|
+
export declare function resolveCodeAssetForOrder(orderId: string, viewerUid: string, isStaff: boolean): Promise<CodeAssetResolution>;
|
|
46
|
+
/**
|
|
47
|
+
* The response headers for a delivered asset.
|
|
48
|
+
*
|
|
49
|
+
* 🛑 `attachment`, ALWAYS — even for an image, where `inline` would be friendlier.
|
|
50
|
+
* The upload surface accepts files, `finalize`'s magic-byte check proves what a
|
|
51
|
+
* file IS but not that rendering it is safe, and an HTML document served inline
|
|
52
|
+
* from this origin is stored XSS against a signed-in buyer. `nosniff` closes the
|
|
53
|
+
* other half (a browser guessing `text/html` from content it was told was
|
|
54
|
+
* `application/octet-stream`).
|
|
55
|
+
*
|
|
56
|
+
* `private, no-store` because a shared cache must never hold one buyer's
|
|
57
|
+
* purchase — this URL is identical for every caller and only the session
|
|
58
|
+
* distinguishes them.
|
|
59
|
+
*/
|
|
60
|
+
export declare function codeAssetHeaders(contentType: string, fileName: string): Record<string, string>;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streams the digital ASSET a buyer paid for.
|
|
3
|
+
*
|
|
4
|
+
* 🛑 WHY THIS ROUTE EXISTS AT ALL, rather than a `/media/{slug}` URL.
|
|
5
|
+
*
|
|
6
|
+
* Two independent facts make the ordinary media pipeline unusable for paid
|
|
7
|
+
* content, and either one alone would be enough:
|
|
8
|
+
*
|
|
9
|
+
* 1. `storage.rules` grants `allow read: if true` on **every object in the
|
|
10
|
+
* bucket**. The comment above it explains why that is acceptable — the
|
|
11
|
+
* rules are a safety net for a misconfigured client, all real access goes
|
|
12
|
+
* through the Admin SDK — but it means Storage itself protects nothing.
|
|
13
|
+
* 2. `GET /api/media/[...slug]` performs **no authentication whatsoever**. It
|
|
14
|
+
* is a bare exported handler with no session read, deliberately, because
|
|
15
|
+
* its job is to serve public product photography through a watermarker.
|
|
16
|
+
* It also resolves legacy multi-segment slugs as raw storage paths.
|
|
17
|
+
*
|
|
18
|
+
* Together: anything reachable at a `/media/` slug is world-readable, and this
|
|
19
|
+
* codebase's media filenames are content-derived and therefore guessable. A
|
|
20
|
+
* purchased QR living there would be a paid good at a predictable public URL.
|
|
21
|
+
*
|
|
22
|
+
* So the bytes live under a private prefix that mints no `mediaAssets` row (and
|
|
23
|
+
* therefore no slug), and are streamed only from here — after re-checking that
|
|
24
|
+
* the caller owns the order. That is the same shape as
|
|
25
|
+
* `GET /api/user/orders/[id]/invoice`, the one existing precedent in this
|
|
26
|
+
* codebase for a gated download.
|
|
27
|
+
*/
|
|
28
|
+
import { getAdminDb } from "../../../../providers/db-firebase/admin";
|
|
29
|
+
import { getAdminStorage } from "../../../../providers/db-firebase";
|
|
30
|
+
import { normalizeError } from "../../../../errors/normalize";
|
|
31
|
+
import { ORDER_FIELDS } from "../../../../constants/field-names";
|
|
32
|
+
import { PRODUCT_COLLECTION, PRODUCT_CODES_SUBCOLLECTION, } from "../../../../features/products/schemas/firestore";
|
|
33
|
+
/** Order statuses at which delivered content may be downloaded. */
|
|
34
|
+
export const CODE_DOWNLOAD_STATUSES = new Set([
|
|
35
|
+
"confirmed",
|
|
36
|
+
"processing",
|
|
37
|
+
"delivered",
|
|
38
|
+
]);
|
|
39
|
+
/**
|
|
40
|
+
* Resolve and read the asset for `orderId`, or explain why not.
|
|
41
|
+
*
|
|
42
|
+
* `viewerUid` is the authenticated caller. `isStaff` lets admin/moderator open a
|
|
43
|
+
* buyer's delivery for support — the same escape hatch the invoice route has.
|
|
44
|
+
*/
|
|
45
|
+
export async function resolveCodeAssetForOrder(orderId, viewerUid, isStaff) {
|
|
46
|
+
const db = getAdminDb();
|
|
47
|
+
const orderSnap = await db.collection("orders").doc(orderId).get();
|
|
48
|
+
if (!orderSnap.exists) {
|
|
49
|
+
return { ok: false, status: 404, message: "Order not found" };
|
|
50
|
+
}
|
|
51
|
+
const order = orderSnap.data();
|
|
52
|
+
/*
|
|
53
|
+
* 404, not 403, when it is not your order — matching the reveal route and the
|
|
54
|
+
* order-invoice route. Answering 403 would confirm that an order with that id
|
|
55
|
+
* exists, which is a membership oracle over a guessable id space.
|
|
56
|
+
*/
|
|
57
|
+
if (!isStaff && order.userId !== viewerUid) {
|
|
58
|
+
return { ok: false, status: 404, message: "Order not found" };
|
|
59
|
+
}
|
|
60
|
+
if (!CODE_DOWNLOAD_STATUSES.has(String(order.status ?? "").toLowerCase())) {
|
|
61
|
+
return {
|
|
62
|
+
ok: false,
|
|
63
|
+
status: 400,
|
|
64
|
+
message: "This order is not in a state where content can be downloaded.",
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const productId = order.items?.[0]?.productId ?? order.productId;
|
|
68
|
+
if (!productId) {
|
|
69
|
+
return { ok: false, status: 404, message: "No content found for this order" };
|
|
70
|
+
}
|
|
71
|
+
const snap = await db
|
|
72
|
+
.collection(PRODUCT_COLLECTION)
|
|
73
|
+
.doc(productId)
|
|
74
|
+
.collection(PRODUCT_CODES_SUBCOLLECTION)
|
|
75
|
+
.where("orderId", "==", orderId)
|
|
76
|
+
.where(ORDER_FIELDS.STATUS, "==", "claimed")
|
|
77
|
+
.limit(1)
|
|
78
|
+
.get();
|
|
79
|
+
if (snap.empty) {
|
|
80
|
+
return { ok: false, status: 404, message: "No content found for this order" };
|
|
81
|
+
}
|
|
82
|
+
const entry = snap.docs[0].data();
|
|
83
|
+
if (!entry.assetPath) {
|
|
84
|
+
return {
|
|
85
|
+
ok: false,
|
|
86
|
+
status: 400,
|
|
87
|
+
message: "This delivery is a code, not a downloadable file.",
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
const file = getAdminStorage().bucket().file(entry.assetPath);
|
|
92
|
+
const [buffer] = await file.download();
|
|
93
|
+
return {
|
|
94
|
+
ok: true,
|
|
95
|
+
buffer,
|
|
96
|
+
contentType: entry.contentType ?? "application/octet-stream",
|
|
97
|
+
fileName: entry.fileName ?? "download",
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
void normalizeError(err);
|
|
102
|
+
return { ok: false, status: 404, message: "Content is no longer available" };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The response headers for a delivered asset.
|
|
107
|
+
*
|
|
108
|
+
* 🛑 `attachment`, ALWAYS — even for an image, where `inline` would be friendlier.
|
|
109
|
+
* The upload surface accepts files, `finalize`'s magic-byte check proves what a
|
|
110
|
+
* file IS but not that rendering it is safe, and an HTML document served inline
|
|
111
|
+
* from this origin is stored XSS against a signed-in buyer. `nosniff` closes the
|
|
112
|
+
* other half (a browser guessing `text/html` from content it was told was
|
|
113
|
+
* `application/octet-stream`).
|
|
114
|
+
*
|
|
115
|
+
* `private, no-store` because a shared cache must never hold one buyer's
|
|
116
|
+
* purchase — this URL is identical for every caller and only the session
|
|
117
|
+
* distinguishes them.
|
|
118
|
+
*/
|
|
119
|
+
export function codeAssetHeaders(contentType, fileName) {
|
|
120
|
+
// Strip anything that could break out of the quoted filename or inject a header.
|
|
121
|
+
const safeName = fileName.replace(/[^\w.\-() ]+/g, "_").slice(0, 120) || "download";
|
|
122
|
+
return {
|
|
123
|
+
"Content-Type": contentType,
|
|
124
|
+
"Content-Disposition": `attachment; filename="${safeName}"`,
|
|
125
|
+
"X-Content-Type-Options": "nosniff",
|
|
126
|
+
"Cache-Control": "private, no-store, max-age=0",
|
|
127
|
+
};
|
|
128
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The digital-content pool for a `digital-code` listing.
|
|
3
|
+
*
|
|
4
|
+
* 🛑 THIS IS THE MISSING HALF OF THE FEATURE. Everything downstream already
|
|
5
|
+
* existed — `claimDigitalCodeForOrder` claims one at checkout, the buyer reveals
|
|
6
|
+
* it through `GET /api/orders/{id}/code`, a refund revokes unclaimed ones — but
|
|
7
|
+
* **nothing in the codebase had ever WRITTEN a row into the pool.** The one
|
|
8
|
+
* seller-facing route returned `501 "Digital code management is not implemented
|
|
9
|
+
* yet."` So every purchase of a digital-code listing hit
|
|
10
|
+
* `serverLogger.warn("claimDigitalCode: code pool exhausted")` and returned
|
|
11
|
+
* silently, the order completed normally, and the buyer's reveal panel answered
|
|
12
|
+
* 404. The seller form even has a "Code Pool Size" field, which made the pool
|
|
13
|
+
* look populated on the listing page while it was always empty.
|
|
14
|
+
*
|
|
15
|
+
* A "digital code" delivers digital CONTENT: a redemption string, a QR image, or
|
|
16
|
+
* a file. `contentKind` says which, defaulting to `"code"` so every pre-existing
|
|
17
|
+
* document keeps its meaning with no migration.
|
|
18
|
+
*/
|
|
19
|
+
import { type ProductCodeContentKind } from "../../../../features/products/schemas/firestore";
|
|
20
|
+
/**
|
|
21
|
+
* Where a delivered asset's bytes live.
|
|
22
|
+
*
|
|
23
|
+
* Deliberately OUTSIDE every prefix the public media proxy knows how to resolve,
|
|
24
|
+
* and no `mediaAssets` row is ever created for it — a `mediaAssets` row is what
|
|
25
|
+
* mints a `/media/{shortId}` slug, and that slug is served without
|
|
26
|
+
* authentication. No row, no slug, no public URL.
|
|
27
|
+
*/
|
|
28
|
+
export declare const DIGITAL_CONTENT_PREFIX = "private/digital-content";
|
|
29
|
+
/**
|
|
30
|
+
* 🛑 THE PERMISSION SPLIT, and the reason it is not simply "admins only".
|
|
31
|
+
*
|
|
32
|
+
* An image is the QR/voucher case and is the whole point of widening this from
|
|
33
|
+
* strings, so a seller must be able to add one — they are the person who has it.
|
|
34
|
+
* Any OTHER file type is admin-only to upload: the upload surface accepts bytes
|
|
35
|
+
* that will later be streamed to a buyer, and a `.html` renamed `.pdf` is stored
|
|
36
|
+
* XSS the moment anything renders it inline. Narrowing who can put a non-image
|
|
37
|
+
* file into that path is the cheap half of the defence; the magic-byte check and
|
|
38
|
+
* the forced `Content-Disposition: attachment` are the other half.
|
|
39
|
+
*
|
|
40
|
+
* READ is a third thing again and is not on this axis at all: whoever may
|
|
41
|
+
* upload, only the buyer who paid for that order (or staff) may download. See
|
|
42
|
+
* `assertCanDownloadCodeAsset`.
|
|
43
|
+
*/
|
|
44
|
+
export declare function assertCanUploadContentKind(kind: ProductCodeContentKind, role: string | undefined): void;
|
|
45
|
+
export interface AddPoolEntryInput {
|
|
46
|
+
productId: string;
|
|
47
|
+
contentKind: ProductCodeContentKind;
|
|
48
|
+
/** Required when `contentKind === "code"`. */
|
|
49
|
+
code?: string;
|
|
50
|
+
/** Required for the asset kinds — a raw Storage path under DIGITAL_CONTENT_PREFIX. */
|
|
51
|
+
assetPath?: string;
|
|
52
|
+
fileName?: string;
|
|
53
|
+
contentType?: string;
|
|
54
|
+
expiresAt?: Date;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Append entries to a product's pool and re-sync the two counters.
|
|
58
|
+
*
|
|
59
|
+
* `codePoolSize` and `codesAvailable` are recomputed from the subcollection, not
|
|
60
|
+
* incremented from the input — the seller form lets a human type `codePoolSize`
|
|
61
|
+
* by hand, so treating that number as authoritative would let the listing page
|
|
62
|
+
* advertise stock that does not exist. Counting is the only thing that cannot
|
|
63
|
+
* drift (Root Cause #42: a mirror is only as good as every writer of it).
|
|
64
|
+
*/
|
|
65
|
+
export declare function addPoolEntries(entries: AddPoolEntryInput[]): Promise<{
|
|
66
|
+
added: number;
|
|
67
|
+
available: number;
|
|
68
|
+
total: number;
|
|
69
|
+
}>;
|
|
70
|
+
/**
|
|
71
|
+
* Recount the pool and write both counters onto the product.
|
|
72
|
+
*
|
|
73
|
+
* 🛑 `codesAvailable` HAD NO WRITER EITHER. `claimDigitalCodeForOrder` flips a
|
|
74
|
+
* code to `claimed` and never decrements it, so the counter only ever held
|
|
75
|
+
* whatever the seed or the seller typed. Since `hasCodesAvailable()` is what the
|
|
76
|
+
* availability predicate reads, a sold-out listing stayed "available" forever and
|
|
77
|
+
* a listing that never had codes still advertised stock. Both counters are
|
|
78
|
+
* derived here, so neither can drift from the subcollection.
|
|
79
|
+
*/
|
|
80
|
+
export declare function recountPool(productId: string): Promise<{
|
|
81
|
+
available: number;
|
|
82
|
+
total: number;
|
|
83
|
+
}>;
|
|
84
|
+
/** The seller/admin view of a pool. Never returns `code` or `assetPath`. */
|
|
85
|
+
export declare function listPoolEntries(productId: string): Promise<Array<{
|
|
86
|
+
id: string;
|
|
87
|
+
contentKind: ProductCodeContentKind;
|
|
88
|
+
status: string;
|
|
89
|
+
fileName?: string;
|
|
90
|
+
orderId?: string;
|
|
91
|
+
claimedAt?: string;
|
|
92
|
+
createdAt?: string;
|
|
93
|
+
}>>;
|
|
94
|
+
/**
|
|
95
|
+
* Remove an UNCLAIMED entry. A claimed one is somebody's purchase and deleting
|
|
96
|
+
* it would make their reveal 404 with no trace of what they bought.
|
|
97
|
+
*/
|
|
98
|
+
export declare function deletePoolEntry(productId: string, codeId: string): Promise<{
|
|
99
|
+
available: number;
|
|
100
|
+
total: number;
|
|
101
|
+
}>;
|