@striae-org/striae 3.2.2 → 4.0.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.
Files changed (82) hide show
  1. package/.env.example +1 -1
  2. package/app/components/actions/case-export/core-export.ts +5 -2
  3. package/app/components/actions/case-export/download-handlers.ts +51 -3
  4. package/app/components/actions/case-import/confirmation-import.ts +65 -40
  5. package/app/components/actions/case-import/confirmation-package.ts +86 -0
  6. package/app/components/actions/case-import/image-operations.ts +20 -49
  7. package/app/components/actions/case-import/index.ts +1 -0
  8. package/app/components/actions/case-import/orchestrator.ts +13 -3
  9. package/app/components/actions/case-import/storage-operations.ts +54 -89
  10. package/app/components/actions/case-import/validation.ts +7 -111
  11. package/app/components/actions/case-import/zip-processing.ts +44 -2
  12. package/app/components/actions/case-manage.ts +15 -27
  13. package/app/components/actions/confirm-export.ts +44 -13
  14. package/app/components/actions/generate-pdf.ts +3 -7
  15. package/app/components/actions/image-manage.ts +63 -129
  16. package/app/components/button/button.module.css +12 -8
  17. package/app/components/form/form-button.tsx +1 -1
  18. package/app/components/form/form.module.css +9 -0
  19. package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +163 -49
  20. package/app/components/public-signing-key-modal/public-signing-key-modal.tsx +365 -88
  21. package/app/components/sidebar/case-export/case-export.tsx +13 -60
  22. package/app/components/sidebar/case-import/case-import.tsx +18 -6
  23. package/app/components/sidebar/case-import/hooks/useFilePreview.ts +6 -4
  24. package/app/components/sidebar/case-import/utils/file-validation.ts +57 -2
  25. package/app/components/sidebar/cases/case-sidebar.tsx +122 -52
  26. package/app/components/sidebar/cases/cases.module.css +101 -18
  27. package/app/components/sidebar/notes/notes.module.css +33 -13
  28. package/app/components/sidebar/sidebar.module.css +0 -2
  29. package/app/components/user/delete-account.tsx +7 -7
  30. package/app/components/user/manage-profile.tsx +1 -1
  31. package/app/components/user/mfa-phone-update.tsx +15 -12
  32. package/app/config-example/config.json +2 -8
  33. package/app/hooks/useInactivityTimeout.ts +2 -5
  34. package/app/root.tsx +96 -65
  35. package/app/routes/auth/login.tsx +132 -11
  36. package/app/routes/auth/route.ts +4 -3
  37. package/app/routes/striae/striae.tsx +4 -8
  38. package/app/services/audit/audit-api-client.ts +40 -0
  39. package/app/services/audit/audit-worker-client.ts +14 -17
  40. package/app/styles/root.module.css +13 -101
  41. package/app/tailwind.css +9 -2
  42. package/app/utils/SHA256.ts +5 -1
  43. package/app/utils/auth.ts +5 -32
  44. package/app/utils/confirmation-signature.ts +5 -1
  45. package/app/utils/data-api-client.ts +43 -0
  46. package/app/utils/data-operations.ts +59 -75
  47. package/app/utils/export-verification.ts +353 -0
  48. package/app/utils/image-api-client.ts +130 -0
  49. package/app/utils/pdf-api-client.ts +43 -0
  50. package/app/utils/permissions.ts +10 -23
  51. package/app/utils/signature-utils.ts +74 -4
  52. package/app/utils/user-api-client.ts +90 -0
  53. package/functions/api/_shared/firebase-auth.ts +255 -0
  54. package/functions/api/audit/[[path]].ts +150 -0
  55. package/functions/api/data/[[path]].ts +141 -0
  56. package/functions/api/image/[[path]].ts +127 -0
  57. package/functions/api/pdf/[[path]].ts +110 -0
  58. package/functions/api/user/[[path]].ts +196 -0
  59. package/package.json +8 -4
  60. package/public/favicon.ico +0 -0
  61. package/public/icon-256.png +0 -0
  62. package/public/icon-512.png +0 -0
  63. package/public/manifest.json +39 -0
  64. package/public/shortcut.png +0 -0
  65. package/public/social-image.png +0 -0
  66. package/react-router.config.ts +5 -0
  67. package/scripts/deploy-all.sh +22 -8
  68. package/scripts/deploy-config.sh +143 -148
  69. package/scripts/deploy-pages-secrets.sh +231 -0
  70. package/scripts/deploy-worker-secrets.sh +1 -1
  71. package/workers/audit-worker/wrangler.jsonc.example +1 -8
  72. package/workers/data-worker/wrangler.jsonc.example +1 -8
  73. package/workers/image-worker/wrangler.jsonc.example +1 -8
  74. package/workers/keys-worker/wrangler.jsonc.example +2 -9
  75. package/workers/pdf-worker/scripts/generate-assets.js +94 -0
  76. package/workers/pdf-worker/src/assets/icon-256.png +0 -0
  77. package/workers/pdf-worker/wrangler.jsonc.example +1 -8
  78. package/workers/user-worker/src/user-worker.example.ts +121 -41
  79. package/workers/user-worker/wrangler.jsonc.example +1 -8
  80. package/wrangler.toml.example +1 -1
  81. package/app/styles/legal-pages.module.css +0 -113
  82. package/public/favicon.svg +0 -9
@@ -0,0 +1,231 @@
1
+ #!/bin/bash
2
+
3
+ # ======================================
4
+ # STRIAE PAGES SECRETS DEPLOYMENT SCRIPT
5
+ # ======================================
6
+ # This script deploys required secrets to Cloudflare Pages environments.
7
+
8
+ set -e
9
+ set -o pipefail
10
+
11
+ # Colors for output
12
+ RED='\033[0;31m'
13
+ GREEN='\033[0;32m'
14
+ YELLOW='\033[1;33m'
15
+ BLUE='\033[0;34m'
16
+ NC='\033[0m' # No Color
17
+
18
+ echo -e "${BLUE}🔐 Striae Pages Secrets Deployment Script${NC}"
19
+ echo "=========================================="
20
+
21
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
22
+ PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
23
+ cd "$PROJECT_ROOT"
24
+
25
+ trap 'echo -e "\n${RED}❌ deploy-pages-secrets.sh failed near line ${LINENO}${NC}"' ERR
26
+
27
+ show_help=false
28
+ deploy_production=true
29
+ deploy_preview=true
30
+
31
+ for arg in "$@"; do
32
+ case "$arg" in
33
+ -h|--help)
34
+ show_help=true
35
+ ;;
36
+ --production-only)
37
+ deploy_production=true
38
+ deploy_preview=false
39
+ ;;
40
+ --preview-only)
41
+ deploy_production=false
42
+ deploy_preview=true
43
+ ;;
44
+ *)
45
+ echo -e "${RED}❌ Unknown option: $arg${NC}"
46
+ echo "Use --help to see supported options."
47
+ exit 1
48
+ ;;
49
+ esac
50
+ done
51
+
52
+ if [ "$show_help" = "true" ]; then
53
+ echo "Usage: bash ./scripts/deploy-pages-secrets.sh [--production-only|--preview-only]"
54
+ echo ""
55
+ echo "Options:"
56
+ echo " --production-only Deploy secrets only to the production Pages environment"
57
+ echo " --preview-only Deploy secrets only to the preview Pages environment"
58
+ echo " -h, --help Show this help message"
59
+ exit 0
60
+ fi
61
+
62
+ if [ "$deploy_production" != "true" ] && [ "$deploy_preview" != "true" ]; then
63
+ echo -e "${RED}❌ No target environment selected${NC}"
64
+ exit 1
65
+ fi
66
+
67
+ require_command() {
68
+ local cmd=$1
69
+ if ! command -v "$cmd" > /dev/null 2>&1; then
70
+ echo -e "${RED}❌ Error: required command '$cmd' is not installed or not in PATH${NC}"
71
+ exit 1
72
+ fi
73
+ }
74
+
75
+ strip_carriage_returns() {
76
+ printf '%s' "$1" | tr -d '\r'
77
+ }
78
+
79
+ is_placeholder() {
80
+ local value="$1"
81
+ local normalized
82
+
83
+ normalized=$(echo "$value" | tr '[:upper:]' '[:lower:]')
84
+
85
+ if [ -z "$normalized" ]; then
86
+ return 0
87
+ fi
88
+
89
+ [[ "$normalized" == your_*_here ]]
90
+ }
91
+
92
+ load_required_project_id() {
93
+ local admin_service_path="app/config/admin-service.json"
94
+ local service_project_id
95
+
96
+ if [ ! -f "$admin_service_path" ]; then
97
+ echo -e "${RED}❌ Error: Required Firebase admin service file not found: $admin_service_path${NC}"
98
+ echo -e "${YELLOW} Create app/config/admin-service.json before deploying Pages secrets.${NC}"
99
+ exit 1
100
+ fi
101
+
102
+ if ! service_project_id=$(node -e "const fs=require('fs'); const data=JSON.parse(fs.readFileSync(process.argv[1], 'utf8')); process.stdout.write(data.project_id || '');" "$admin_service_path"); then
103
+ echo -e "${RED}❌ Error: Could not parse project_id from $admin_service_path${NC}"
104
+ exit 1
105
+ fi
106
+
107
+ service_project_id=$(strip_carriage_returns "$service_project_id")
108
+
109
+ if [ -z "$service_project_id" ] || is_placeholder "$service_project_id"; then
110
+ echo -e "${RED}❌ Error: project_id in $admin_service_path is missing or placeholder${NC}"
111
+ exit 1
112
+ fi
113
+
114
+ PROJECT_ID="$service_project_id"
115
+ export PROJECT_ID
116
+
117
+ echo -e "${GREEN}✅ Loaded PROJECT_ID from $admin_service_path${NC}"
118
+ }
119
+
120
+ get_required_value() {
121
+ local var_name=$1
122
+ local value="${!var_name}"
123
+
124
+ value=$(strip_carriage_returns "$value")
125
+
126
+ if [ -z "$value" ] || is_placeholder "$value"; then
127
+ echo -e "${RED}❌ Error: required value for $var_name is missing or placeholder${NC}" >&2
128
+ exit 1
129
+ fi
130
+
131
+ printf '%s' "$value"
132
+ }
133
+
134
+ get_optional_value() {
135
+ local var_name=$1
136
+ local value="${!var_name}"
137
+
138
+ value=$(strip_carriage_returns "$value")
139
+
140
+ if [ -z "$value" ] || is_placeholder "$value"; then
141
+ printf ''
142
+ return 0
143
+ fi
144
+
145
+ printf '%s' "$value"
146
+ }
147
+
148
+ set_pages_secret() {
149
+ local secret_name=$1
150
+ local secret_value=$2
151
+ local pages_env=$3
152
+
153
+ echo -e "${YELLOW} Setting $secret_name for $pages_env...${NC}"
154
+
155
+ if [ "$pages_env" = "production" ]; then
156
+ printf '%s' "$secret_value" | wrangler pages secret put "$secret_name" --project-name "$PAGES_PROJECT_NAME"
157
+ return 0
158
+ fi
159
+
160
+ printf '%s' "$secret_value" | wrangler pages secret put "$secret_name" --project-name "$PAGES_PROJECT_NAME" --env "$pages_env"
161
+ }
162
+
163
+ deploy_pages_environment_secrets() {
164
+ local pages_env=$1
165
+ local secret
166
+ local secret_value
167
+
168
+ echo -e "\n${BLUE}🔧 Deploying Pages secrets to $pages_env...${NC}"
169
+
170
+ for secret in "${required_pages_secrets[@]}"; do
171
+ secret_value=$(get_required_value "$secret")
172
+ set_pages_secret "$secret" "$secret_value" "$pages_env"
173
+ done
174
+
175
+ local optional_api_token
176
+ optional_api_token=$(get_optional_value "API_TOKEN")
177
+ if [ -n "$optional_api_token" ]; then
178
+ set_pages_secret "API_TOKEN" "$optional_api_token" "$pages_env"
179
+ fi
180
+
181
+ echo -e "${GREEN}✅ Pages secrets deployed to $pages_env${NC}"
182
+ }
183
+
184
+ require_command wrangler
185
+ require_command node
186
+
187
+ if [ ! -f ".env" ]; then
188
+ echo -e "${RED}❌ Error: .env file not found${NC}"
189
+ echo -e "${YELLOW} Run deploy-config first to generate and populate .env.${NC}"
190
+ exit 1
191
+ fi
192
+
193
+ echo -e "${YELLOW}📖 Loading environment variables from .env...${NC}"
194
+ source .env
195
+
196
+ load_required_project_id
197
+
198
+ PAGES_PROJECT_NAME=$(strip_carriage_returns "$PAGES_PROJECT_NAME")
199
+ if [ -z "$PAGES_PROJECT_NAME" ] || is_placeholder "$PAGES_PROJECT_NAME"; then
200
+ echo -e "${RED}❌ Error: PAGES_PROJECT_NAME is missing or placeholder in .env${NC}"
201
+ exit 1
202
+ fi
203
+
204
+ required_pages_secrets=(
205
+ "AUDIT_WORKER_DOMAIN"
206
+ "DATA_WORKER_DOMAIN"
207
+ "IMAGES_API_TOKEN"
208
+ "IMAGES_WORKER_DOMAIN"
209
+ "PDF_WORKER_AUTH"
210
+ "PDF_WORKER_DOMAIN"
211
+ "PROJECT_ID"
212
+ "R2_KEY_SECRET"
213
+ "USER_DB_AUTH"
214
+ "USER_WORKER_DOMAIN"
215
+ )
216
+
217
+ echo -e "${YELLOW}🔍 Validating required Pages secret values...${NC}"
218
+ for secret in "${required_pages_secrets[@]}"; do
219
+ get_required_value "$secret" > /dev/null
220
+ done
221
+ echo -e "${GREEN}✅ Required Pages secret values found${NC}"
222
+
223
+ if [ "$deploy_production" = "true" ]; then
224
+ deploy_pages_environment_secrets "production"
225
+ fi
226
+
227
+ if [ "$deploy_preview" = "true" ]; then
228
+ deploy_pages_environment_secrets "preview"
229
+ fi
230
+
231
+ echo -e "\n${GREEN}🎉 Pages secrets deployment completed!${NC}"
@@ -181,7 +181,7 @@ fi
181
181
 
182
182
  # User Worker
183
183
  if ! set_worker_secrets "User Worker" "workers/user-worker" \
184
- "USER_DB_AUTH" "R2_KEY_SECRET" "IMAGES_API_TOKEN" "PROJECT_ID" "FIREBASE_SERVICE_ACCOUNT_EMAIL" "FIREBASE_SERVICE_ACCOUNT_PRIVATE_KEY"; then
184
+ "USER_DB_AUTH" "R2_KEY_SECRET" "IMAGES_API_TOKEN" "DATA_WORKER_DOMAIN" "IMAGES_WORKER_DOMAIN" "PROJECT_ID" "FIREBASE_SERVICE_ACCOUNT_EMAIL" "FIREBASE_SERVICE_ACCOUNT_PRIVATE_KEY"; then
185
185
  echo -e "${YELLOW}⚠️ Skipping User Worker (not configured)${NC}"
186
186
  fi
187
187
 
@@ -2,7 +2,7 @@
2
2
  "name": "AUDIT_WORKER_NAME",
3
3
  "account_id": "ACCOUNT_ID",
4
4
  "main": "src/audit-worker.ts",
5
- "compatibility_date": "2026-03-14",
5
+ "compatibility_date": "2026-03-15",
6
6
  "compatibility_flags": [
7
7
  "nodejs_compat"
8
8
  ],
@@ -18,12 +18,5 @@
18
18
  }
19
19
  ],
20
20
 
21
- "routes": [
22
- {
23
- "pattern": "AUDIT_WORKER_DOMAIN",
24
- "custom_domain": true
25
- }
26
- ],
27
-
28
21
  "placement": { "mode": "smart" }
29
22
  }
@@ -3,7 +3,7 @@
3
3
  "name": "DATA_WORKER_NAME",
4
4
  "account_id": "ACCOUNT_ID",
5
5
  "main": "src/data-worker.ts",
6
- "compatibility_date": "2026-03-14",
6
+ "compatibility_date": "2026-03-15",
7
7
  "compatibility_flags": [
8
8
  "nodejs_compat"
9
9
  ],
@@ -19,12 +19,5 @@
19
19
  }
20
20
  ],
21
21
 
22
- "routes": [
23
- {
24
- "pattern": "DATA_WORKER_DOMAIN",
25
- "custom_domain": true
26
- }
27
- ],
28
-
29
22
  "placement": { "mode": "smart" }
30
23
  }
@@ -2,7 +2,7 @@
2
2
  "name": "IMAGES_WORKER_NAME",
3
3
  "account_id": "ACCOUNT_ID",
4
4
  "main": "src/image-worker.ts",
5
- "compatibility_date": "2026-03-14",
5
+ "compatibility_date": "2026-03-15",
6
6
  "compatibility_flags": [
7
7
  "nodejs_compat"
8
8
  ],
@@ -10,13 +10,6 @@
10
10
  "observability": {
11
11
  "enabled": true
12
12
  },
13
-
14
- "routes": [
15
- {
16
- "pattern": "IMAGES_WORKER_DOMAIN",
17
- "custom_domain": true
18
- }
19
- ],
20
13
 
21
14
  "placement": { "mode": "smart" }
22
15
  }
@@ -2,7 +2,7 @@
2
2
  "name": "KEYS_WORKER_NAME",
3
3
  "account_id": "ACCOUNT_ID",
4
4
  "main": "src/keys.ts",
5
- "compatibility_date": "2026-03-14",
5
+ "compatibility_date": "2026-03-15",
6
6
  "compatibility_flags": [
7
7
  "nodejs_compat"
8
8
  ],
@@ -10,13 +10,6 @@
10
10
  "observability": {
11
11
  "enabled": true
12
12
  },
13
-
14
- "routes": [
15
- {
16
- "pattern": "KEYS_WORKER_DOMAIN",
17
- "custom_domain": true
18
- }
19
- ],
20
-
13
+
21
14
  "placement": { "mode": "smart" }
22
15
  }
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Scans src/assets/ and generates src/assets/generated-assets.ts with one named
4
+ * export per file. Run via: npm run generate:assets
5
+ *
6
+ * Naming rule: filename → remove extension → uppercase → non-alphanumeric → _
7
+ * icon-256.png → ICON_256
8
+ * logo.svg → LOGO
9
+ * brand-mark.webp → BRAND_MARK
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+
15
+ const ASSETS_DIR = path.join(__dirname, '../src/assets');
16
+ const OUTPUT_FILE = path.join(__dirname, '../src/assets/generated-assets.ts');
17
+
18
+ const MIME_TYPES = {
19
+ '.png': 'image/png',
20
+ '.jpg': 'image/jpeg',
21
+ '.jpeg': 'image/jpeg',
22
+ '.svg': 'image/svg+xml',
23
+ '.gif': 'image/gif',
24
+ '.webp': 'image/webp',
25
+ '.ico': 'image/x-icon',
26
+ };
27
+
28
+ function toConstName(filename) {
29
+ return filename
30
+ .replace(/\.[^.]+$/, '') // strip extension
31
+ .toUpperCase()
32
+ .replace(/[^A-Z0-9]+/g, '_') // non-alphanumeric → underscore
33
+ .replace(/^_+|_+$/g, ''); // trim leading/trailing underscores
34
+ }
35
+
36
+ function toDataUri(filepath) {
37
+ const ext = path.extname(filepath).toLowerCase();
38
+ const mime = MIME_TYPES[ext];
39
+ if (!mime) return null;
40
+
41
+ const data = fs.readFileSync(filepath);
42
+ const b64 = data.toString('base64');
43
+ const chunks = b64.match(/.{1,120}/g) || [];
44
+
45
+ const lines = [];
46
+ lines.push(` "data:${mime};base64," +`);
47
+ chunks.forEach((chunk, i) => {
48
+ lines.push(` "${chunk}"${i === chunks.length - 1 ? ';' : ' +'}`);
49
+ });
50
+ return lines.join('\n');
51
+ }
52
+
53
+ if (!fs.existsSync(ASSETS_DIR)) {
54
+ console.error(`assets directory not found: ${ASSETS_DIR}`);
55
+ process.exit(1);
56
+ }
57
+
58
+ const files = fs
59
+ .readdirSync(ASSETS_DIR)
60
+ .filter((f) => Object.keys(MIME_TYPES).includes(path.extname(f).toLowerCase()))
61
+ .sort();
62
+
63
+ if (files.length === 0) {
64
+ console.warn('No supported asset files found in src/assets/.');
65
+ process.exit(0);
66
+ }
67
+
68
+ const sections = [
69
+ '// Auto-generated by scripts/generate-assets.js',
70
+ '// Do not edit manually — run `npm run generate:assets` to regenerate.',
71
+ '',
72
+ ];
73
+
74
+ const exported = [];
75
+
76
+ for (const file of files) {
77
+ const uri = toDataUri(path.join(ASSETS_DIR, file));
78
+ if (!uri) continue;
79
+
80
+ const constName = toConstName(file);
81
+ exported.push({ file, constName });
82
+
83
+ sections.push(`// Source: src/assets/${file}`);
84
+ sections.push(`export const ${constName} =`);
85
+ sections.push(uri);
86
+ sections.push('');
87
+ }
88
+
89
+ fs.writeFileSync(OUTPUT_FILE, sections.join('\n'));
90
+
91
+ console.log(`Generated ${OUTPUT_FILE}`);
92
+ exported.forEach(({ file, constName }) =>
93
+ console.log(` ${constName.padEnd(30)} ← src/assets/${file}`)
94
+ );
@@ -2,7 +2,7 @@
2
2
  "name": "PDF_WORKER_NAME",
3
3
  "account_id": "ACCOUNT_ID",
4
4
  "main": "src/pdf-worker.ts",
5
- "compatibility_date": "2026-03-14",
5
+ "compatibility_date": "2026-03-15",
6
6
  "compatibility_flags": [
7
7
  "nodejs_compat"
8
8
  ],
@@ -15,12 +15,5 @@
15
15
  "enabled": true
16
16
  },
17
17
 
18
- "routes": [
19
- {
20
- "pattern": "PDF_WORKER_DOMAIN",
21
- "custom_domain": true
22
- }
23
- ],
24
-
25
18
  "placement": { "mode": "smart" }
26
19
  }
@@ -3,6 +3,8 @@ interface Env {
3
3
  USER_DB: KVNamespace;
4
4
  R2_KEY_SECRET: string;
5
5
  IMAGES_API_TOKEN: string;
6
+ DATA_WORKER_DOMAIN?: string;
7
+ IMAGES_WORKER_DOMAIN?: string;
6
8
  PROJECT_ID: string;
7
9
  FIREBASE_SERVICE_ACCOUNT_EMAIL: string;
8
10
  FIREBASE_SERVICE_ACCOUNT_PRIVATE_KEY: string;
@@ -84,9 +86,8 @@ const corsHeaders: Record<string, string> = {
84
86
  };
85
87
 
86
88
  // Worker URLs - configure these for deployment
87
- const DATA_WORKER_URL = 'DATA_WORKER_DOMAIN';
88
-
89
- const IMAGE_WORKER_URL = 'IMAGES_WORKER_DOMAIN';
89
+ const DEFAULT_DATA_WORKER_BASE_URL = 'DATA_WORKER_DOMAIN';
90
+ const DEFAULT_IMAGE_WORKER_BASE_URL = 'IMAGES_WORKER_DOMAIN';
90
91
 
91
92
  const GOOGLE_OAUTH_TOKEN_URL = 'https://oauth2.googleapis.com/token';
92
93
  const FIREBASE_IDENTITY_TOOLKIT_BASE_URL = 'https://identitytoolkit.googleapis.com/v1/projects';
@@ -98,6 +99,33 @@ async function authenticate(request: Request, env: Env): Promise<void> {
98
99
  if (authKey !== env.USER_DB_AUTH) throw new Error('Unauthorized');
99
100
  }
100
101
 
102
+ function normalizeWorkerBaseUrl(workerDomain: string): string {
103
+ const trimmedDomain = workerDomain.trim().replace(/\/+$/, '');
104
+ if (trimmedDomain.startsWith('http://') || trimmedDomain.startsWith('https://')) {
105
+ return trimmedDomain;
106
+ }
107
+
108
+ return `https://${trimmedDomain}`;
109
+ }
110
+
111
+ function resolveDataWorkerBaseUrl(env: Env): string {
112
+ const configuredDomain = typeof env.DATA_WORKER_DOMAIN === 'string' ? env.DATA_WORKER_DOMAIN.trim() : '';
113
+ if (configuredDomain.length > 0) {
114
+ return normalizeWorkerBaseUrl(configuredDomain);
115
+ }
116
+
117
+ return normalizeWorkerBaseUrl(DEFAULT_DATA_WORKER_BASE_URL);
118
+ }
119
+
120
+ function resolveImageWorkerBaseUrl(env: Env): string {
121
+ const configuredDomain = typeof env.IMAGES_WORKER_DOMAIN === 'string' ? env.IMAGES_WORKER_DOMAIN.trim() : '';
122
+ if (configuredDomain.length > 0) {
123
+ return normalizeWorkerBaseUrl(configuredDomain);
124
+ }
125
+
126
+ return normalizeWorkerBaseUrl(DEFAULT_IMAGE_WORKER_BASE_URL);
127
+ }
128
+
101
129
  function base64UrlEncode(value: string | Uint8Array): string {
102
130
  const bytes = typeof value === 'string' ? textEncoder.encode(value) : value;
103
131
  let binary = '';
@@ -317,49 +345,86 @@ async function deleteSingleCase(env: Env, userUid: string, caseNumber: string):
317
345
  const dataApiKey = env.R2_KEY_SECRET;
318
346
  const imageApiKey = env.IMAGES_API_TOKEN;
319
347
 
320
- try {
321
- // Get case data from data worker
322
- const caseResponse = await fetch(`${DATA_WORKER_URL}/${encodeURIComponent(userUid)}/${encodeURIComponent(caseNumber)}/data.json`, {
323
- headers: { 'X-Custom-Auth-Key': dataApiKey }
324
- });
348
+ const dataWorkerBaseUrl = resolveDataWorkerBaseUrl(env);
349
+ const imageWorkerBaseUrl = resolveImageWorkerBaseUrl(env);
350
+ const encodedUserId = encodeURIComponent(userUid);
351
+ const encodedCaseNumber = encodeURIComponent(caseNumber);
325
352
 
326
- if (!caseResponse.ok) {
327
- return;
328
- }
353
+ const caseResponse = await fetch(`${dataWorkerBaseUrl}/${encodedUserId}/${encodedCaseNumber}/data.json`, {
354
+ headers: { 'X-Custom-Auth-Key': dataApiKey }
355
+ });
329
356
 
330
- const caseData: CaseData = await caseResponse.json();
357
+ if (caseResponse.status === 404) {
358
+ return;
359
+ }
331
360
 
332
- // Delete all files associated with this case
333
- if (caseData.files && caseData.files.length > 0) {
334
- for (const file of caseData.files) {
335
- try {
336
- // Delete image file - correct endpoint
337
- await fetch(`${IMAGE_WORKER_URL}/${encodeURIComponent(file.id)}`, {
338
- method: 'DELETE',
339
- headers: {
340
- 'Authorization': `Bearer ${imageApiKey}`
341
- }
342
- });
343
-
344
- // Delete notes file if exists
345
- await fetch(`${DATA_WORKER_URL}/${encodeURIComponent(userUid)}/${encodeURIComponent(caseNumber)}/${encodeURIComponent(file.id)}/data.json`, {
361
+ if (!caseResponse.ok) {
362
+ throw new Error(`Failed to load case data for deletion (${caseNumber}): ${caseResponse.status}`);
363
+ }
364
+
365
+ const caseData = await caseResponse.json() as CaseData;
366
+ const deletionErrors: string[] = [];
367
+
368
+ // Delete all files associated with this case
369
+ if (caseData.files && caseData.files.length > 0) {
370
+ for (const file of caseData.files) {
371
+ const encodedFileId = encodeURIComponent(file.id);
372
+
373
+ try {
374
+ const imageDeleteResponse = await fetch(`${imageWorkerBaseUrl}/${encodedFileId}`, {
375
+ method: 'DELETE',
376
+ headers: {
377
+ 'Authorization': `Bearer ${imageApiKey}`
378
+ }
379
+ });
380
+
381
+ if (!imageDeleteResponse.ok && imageDeleteResponse.status !== 404) {
382
+ deletionErrors.push(`image ${file.id} delete failed (${imageDeleteResponse.status})`);
383
+ }
384
+ } catch (error) {
385
+ const message = error instanceof Error ? error.message : 'unknown image delete error';
386
+ deletionErrors.push(`image ${file.id} delete threw (${message})`);
387
+ }
388
+
389
+ try {
390
+ const notesDeleteResponse = await fetch(
391
+ `${dataWorkerBaseUrl}/${encodedUserId}/${encodedCaseNumber}/${encodedFileId}/data.json`,
392
+ {
346
393
  method: 'DELETE',
347
394
  headers: { 'X-Custom-Auth-Key': dataApiKey }
348
- });
349
- } catch {
350
- // Continue with other files
395
+ }
396
+ );
397
+
398
+ if (!notesDeleteResponse.ok && notesDeleteResponse.status !== 404) {
399
+ deletionErrors.push(`annotation ${file.id} delete failed (${notesDeleteResponse.status})`);
351
400
  }
401
+ } catch (error) {
402
+ const message = error instanceof Error ? error.message : 'unknown annotation delete error';
403
+ deletionErrors.push(`annotation ${file.id} delete threw (${message})`);
352
404
  }
353
405
  }
406
+ }
354
407
 
355
- // Delete the case data file
356
- await fetch(`${DATA_WORKER_URL}/${encodeURIComponent(userUid)}/${encodeURIComponent(caseNumber)}/data.json`, {
357
- method: 'DELETE',
358
- headers: { 'X-Custom-Auth-Key': dataApiKey }
359
- });
408
+ // Delete case data file
409
+ try {
410
+ const caseDeleteResponse = await fetch(
411
+ `${dataWorkerBaseUrl}/${encodedUserId}/${encodedCaseNumber}/data.json`,
412
+ {
413
+ method: 'DELETE',
414
+ headers: { 'X-Custom-Auth-Key': dataApiKey }
415
+ }
416
+ );
360
417
 
361
- } catch {
362
- // Continue with user deletion even if case deletion fails
418
+ if (!caseDeleteResponse.ok && caseDeleteResponse.status !== 404) {
419
+ deletionErrors.push(`case ${caseNumber} delete failed (${caseDeleteResponse.status})`);
420
+ }
421
+ } catch (error) {
422
+ const message = error instanceof Error ? error.message : 'unknown case delete error';
423
+ deletionErrors.push(`case ${caseNumber} delete threw (${message})`);
424
+ }
425
+
426
+ if (deletionErrors.length > 0) {
427
+ throw new Error(`Case cleanup incomplete for ${caseNumber}: ${deletionErrors.join('; ')}`);
363
428
  }
364
429
  }
365
430
 
@@ -376,11 +441,10 @@ async function executeUserDeletion(
376
441
  const userObject: UserData = JSON.parse(userData);
377
442
  const ownedCases = (userObject.cases || []).map((caseItem) => caseItem.caseNumber);
378
443
  const readOnlyCases = (userObject.readOnlyCases || []).map((caseItem) => caseItem.caseNumber);
379
- const allCaseNumbers = [...ownedCases, ...readOnlyCases];
444
+ const allCaseNumbers = Array.from(new Set([...ownedCases, ...readOnlyCases]));
380
445
  const totalCases = allCaseNumbers.length;
381
446
  let completedCases = 0;
382
-
383
- await deleteFirebaseAuthUser(env, userUid);
447
+ const caseCleanupErrors: string[] = [];
384
448
 
385
449
  reportProgress?.({
386
450
  event: 'start',
@@ -396,17 +460,33 @@ async function executeUserDeletion(
396
460
  currentCaseNumber: caseNumber
397
461
  });
398
462
 
399
- await deleteSingleCase(env, userUid, caseNumber);
463
+ let caseDeletionError: string | null = null;
464
+ try {
465
+ await deleteSingleCase(env, userUid, caseNumber);
466
+ } catch (error) {
467
+ caseDeletionError = error instanceof Error ? error.message : `Case cleanup failed for ${caseNumber}`;
468
+ caseCleanupErrors.push(caseDeletionError);
469
+ console.error(`Case cleanup error for ${caseNumber}:`, error);
470
+ }
471
+
400
472
  completedCases += 1;
401
473
 
402
474
  reportProgress?.({
403
475
  event: 'case-complete',
404
476
  totalCases,
405
477
  completedCases,
406
- currentCaseNumber: caseNumber
478
+ currentCaseNumber: caseNumber,
479
+ success: caseDeletionError === null,
480
+ message: caseDeletionError || undefined
407
481
  });
408
482
  }
409
483
 
484
+ if (caseCleanupErrors.length > 0) {
485
+ throw new Error(`Failed to fully delete all case data: ${caseCleanupErrors.join(' | ')}`);
486
+ }
487
+
488
+ await deleteFirebaseAuthUser(env, userUid);
489
+
410
490
  // Delete the user account from the database
411
491
  await env.USER_DB.delete(userUid);
412
492