Includes: API (Express/TypeScript), Astro site, Python workers, document generators, FCC compliance tools, Canada CRTC formation, Ansible infrastructure, and deployment scripts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
129 lines
4.3 KiB
TypeScript
129 lines
4.3 KiB
TypeScript
import { Router } from "express";
|
|
import { pool } from "../db.js";
|
|
import { v4 as uuidv4 } from "uuid";
|
|
|
|
const router = Router();
|
|
|
|
// POST /api/v1/id-upload/token — Generate a one-time upload token
|
|
// Called when client clicks "Upload from Phone (QR)" on the order form
|
|
router.post("/api/v1/id-upload/token", async (req, res) => {
|
|
try {
|
|
const { customer_email, customer_name, order_reference } = req.body ?? {};
|
|
if (!customer_email) {
|
|
res.status(400).json({ error: "Email is required." });
|
|
return;
|
|
}
|
|
|
|
const token = uuidv4();
|
|
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
|
|
|
|
await pool.query(
|
|
`INSERT INTO id_upload_tokens (token, customer_email, customer_name, order_reference, expires_at)
|
|
VALUES ($1, $2, $3, $4, $5)`,
|
|
[token, customer_email, customer_name || null, order_reference || null, expiresAt],
|
|
);
|
|
|
|
const uploadUrl = `${req.protocol}://${req.get("host")?.replace(/:\d+$/, "")}:4322/upload/id?token=${token}`;
|
|
// In production: https://performancewest.net/upload/id?token=${token}
|
|
|
|
res.status(201).json({
|
|
token,
|
|
upload_url: uploadUrl,
|
|
expires_at: expiresAt.toISOString(),
|
|
});
|
|
} catch (err) {
|
|
console.error("[id-upload] Token creation error:", err);
|
|
res.status(500).json({ error: "Could not create upload token." });
|
|
}
|
|
});
|
|
|
|
// POST /api/v1/id-upload/:token — Upload ID images (multipart/form-data)
|
|
// Called from both desktop file picker and mobile camera upload page
|
|
router.post("/api/v1/id-upload/:token", async (req, res) => {
|
|
try {
|
|
const { token } = req.params;
|
|
|
|
// Validate token
|
|
const tokenResult = await pool.query(
|
|
"SELECT * FROM id_upload_tokens WHERE token = $1",
|
|
[token],
|
|
);
|
|
if (tokenResult.rows.length === 0) {
|
|
res.status(404).json({ error: "Invalid upload token." });
|
|
return;
|
|
}
|
|
const tokenData = tokenResult.rows[0];
|
|
|
|
if (tokenData.used) {
|
|
res.status(410).json({ error: "This upload link has already been used." });
|
|
return;
|
|
}
|
|
if (new Date(tokenData.expires_at) < new Date()) {
|
|
res.status(410).json({ error: "This upload link has expired. Please request a new one." });
|
|
return;
|
|
}
|
|
|
|
// For now, store a placeholder — actual file upload handling requires
|
|
// multer middleware + MinIO upload (configured at deployment)
|
|
const updates: string[] = [];
|
|
const paths: Record<string, string> = tokenData.minio_paths || {};
|
|
|
|
// Check what was uploaded (multipart fields: front, back)
|
|
if (req.body.front_uploaded) {
|
|
updates.push("front_uploaded = TRUE");
|
|
paths.front = `identity-docs/${token}/front.jpg`;
|
|
}
|
|
if (req.body.back_uploaded) {
|
|
updates.push("back_uploaded = TRUE");
|
|
paths.back = `identity-docs/${token}/back.jpg`;
|
|
}
|
|
|
|
const bothDone = (tokenData.front_uploaded || req.body.front_uploaded) &&
|
|
(tokenData.back_uploaded || req.body.back_uploaded);
|
|
if (bothDone) {
|
|
updates.push("used = TRUE");
|
|
}
|
|
|
|
if (updates.length > 0) {
|
|
await pool.query(
|
|
`UPDATE id_upload_tokens SET ${updates.join(", ")}, minio_paths = $1 WHERE token = $2`,
|
|
[JSON.stringify(paths), token],
|
|
);
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
front_uploaded: tokenData.front_uploaded || !!req.body.front_uploaded,
|
|
back_uploaded: tokenData.back_uploaded || !!req.body.back_uploaded,
|
|
complete: bothDone,
|
|
});
|
|
} catch (err) {
|
|
console.error("[id-upload] Upload error:", err);
|
|
res.status(500).json({ error: "Upload failed." });
|
|
}
|
|
});
|
|
|
|
// GET /api/v1/id-upload/:token/status — Check upload status (for desktop polling)
|
|
router.get("/api/v1/id-upload/:token/status", async (req, res) => {
|
|
try {
|
|
const result = await pool.query(
|
|
"SELECT front_uploaded, back_uploaded, used, expires_at FROM id_upload_tokens WHERE token = $1",
|
|
[req.params.token],
|
|
);
|
|
if (result.rows.length === 0) {
|
|
res.status(404).json({ error: "Token not found." });
|
|
return;
|
|
}
|
|
const t = result.rows[0];
|
|
res.json({
|
|
front_uploaded: t.front_uploaded,
|
|
back_uploaded: t.back_uploaded,
|
|
complete: t.front_uploaded && t.back_uploaded,
|
|
expired: new Date(t.expires_at) < new Date(),
|
|
});
|
|
} catch (err) {
|
|
res.status(500).json({ error: "Could not check status." });
|
|
}
|
|
});
|
|
|
|
export default router;
|