🪻 distributed transcription service thistle.dunkirk.sh
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add syncing with existing customers

dunkirk.sh 692627f4 d39cfeca

verified
+315 -115
+62 -5
src/components/admin-users.ts
··· 22 22 @state() error: string | null = null; 23 23 @state() currentUserEmail: string | null = null; 24 24 @state() revokingSubscriptions = new Set<number>(); 25 + @state() syncingSubscriptions = new Set<number>(); 25 26 26 27 static override styles = css` 27 28 :host { ··· 222 223 cursor: not-allowed; 223 224 } 224 225 226 + .sync-btn { 227 + background: transparent; 228 + border: 2px solid var(--primary); 229 + color: var(--primary); 230 + padding: 0.5rem 1rem; 231 + border-radius: 4px; 232 + cursor: pointer; 233 + font-size: 0.875rem; 234 + font-weight: 600; 235 + transition: all 0.2s; 236 + } 237 + 238 + .sync-btn:hover:not(:disabled) { 239 + background: var(--primary); 240 + color: var(--white); 241 + } 242 + 243 + .sync-btn:disabled { 244 + opacity: 0.5; 245 + cursor: not-allowed; 246 + } 247 + 225 248 .subscription-badge { 226 249 background: var(--primary); 227 250 color: var(--white); ··· 256 279 const user = await response.json(); 257 280 this.currentUserEmail = user.email; 258 281 } 259 - } catch (error) { 260 - console.error("Failed to get current user:", error); 282 + } catch { 283 + // Silent fail 261 284 } 262 285 } 263 286 ··· 408 431 // Remove user from local array instead of reloading 409 432 this.users = this.users.filter(u => u.id !== userId); 410 433 this.dispatchEvent(new CustomEvent("user-deleted")); 411 - } catch (error) { 412 - console.error("Failed to delete user:", error); 434 + } catch { 413 435 alert("Failed to delete user. Please try again."); 414 436 } 415 437 } ··· 479 501 await this.loadUsers(); 480 502 alert(`Subscription revoked for ${email}`); 481 503 } catch (error) { 482 - console.error("Failed to revoke subscription:", error); 483 504 alert(`Failed to revoke subscription: ${error instanceof Error ? error.message : "Unknown error"}`); 484 505 this.revokingSubscriptions.delete(userId); 485 506 } 486 507 } 487 508 509 + private async handleSyncSubscription(userId: number, event: Event) { 510 + event.stopPropagation(); 511 + 512 + this.syncingSubscriptions.add(userId); 513 + this.requestUpdate(); 514 + 515 + try { 516 + const response = await fetch(`/api/admin/users/${userId}/subscription`, { 517 + method: "PUT", 518 + headers: { "Content-Type": "application/json" }, 519 + }); 520 + 521 + if (!response.ok) { 522 + const data = await response.json(); 523 + // Don't alert if there's just no subscription 524 + if (response.status !== 404) { 525 + alert(`Failed to sync subscription: ${data.error || "Unknown error"}`); 526 + } 527 + return; 528 + } 529 + 530 + await this.loadUsers(); 531 + } finally { 532 + this.syncingSubscriptions.delete(userId); 533 + this.requestUpdate(); 534 + } 535 + } 536 + 488 537 private getDeleteButtonText(userId: number, type: "user" | "revoke"): string { 489 538 if ( 490 539 !this.deleteState || ··· 623 672 <option value="user">User</option> 624 673 <option value="admin">Admin</option> 625 674 </select> 675 + <button 676 + class="sync-btn" 677 + ?disabled=${this.syncingSubscriptions.has(u.id)} 678 + @click=${(e: Event) => this.handleSyncSubscription(u.id, e)} 679 + title="Sync subscription status from Polar" 680 + > 681 + ${this.syncingSubscriptions.has(u.id) ? "Syncing..." : "🔄 Sync"} 682 + </button> 626 683 <button 627 684 class="revoke-btn" 628 685 ?disabled=${!u.subscription_status || !u.subscription_id || this.revokingSubscriptions.has(u.id)}
+253 -110
src/index.ts
··· 717 717 718 718 try { 719 719 // Get subscription from database 720 - const subscription = db.query< 721 - { 722 - id: string; 723 - status: string; 724 - current_period_start: number | null; 725 - current_period_end: number | null; 726 - cancel_at_period_end: number; 727 - canceled_at: number | null; 728 - }, 729 - [number] 730 - >( 731 - "SELECT id, status, current_period_start, current_period_end, cancel_at_period_end, canceled_at FROM subscriptions WHERE user_id = ? ORDER BY created_at DESC LIMIT 1", 732 - ).get(user.id); 720 + const subscription = db 721 + .query< 722 + { 723 + id: string; 724 + status: string; 725 + current_period_start: number | null; 726 + current_period_end: number | null; 727 + cancel_at_period_end: number; 728 + canceled_at: number | null; 729 + }, 730 + [number] 731 + >( 732 + "SELECT id, status, current_period_start, current_period_end, cancel_at_period_end, canceled_at FROM subscriptions WHERE user_id = ? ORDER BY created_at DESC LIMIT 1", 733 + ) 734 + .get(user.id); 733 735 734 736 if (!subscription) { 735 737 return Response.json({ subscription: null }); ··· 760 762 const { polar } = await import("./lib/polar"); 761 763 762 764 // Get subscription to find customer ID 763 - const subscription = db.query< 764 - { 765 - customer_id: string; 766 - }, 767 - [number] 768 - >( 769 - "SELECT customer_id FROM subscriptions WHERE user_id = ? ORDER BY created_at DESC LIMIT 1", 770 - ).get(user.id); 765 + const subscription = db 766 + .query< 767 + { 768 + customer_id: string; 769 + }, 770 + [number] 771 + >( 772 + "SELECT customer_id FROM subscriptions WHERE user_id = ? ORDER BY created_at DESC LIMIT 1", 773 + ) 774 + .get(user.id); 771 775 772 776 if (!subscription || !subscription.customer_id) { 773 777 return Response.json( ··· 804 808 const webhookSecret = process.env.POLAR_WEBHOOK_SECRET; 805 809 if (!webhookSecret) { 806 810 console.error("[Webhook] POLAR_WEBHOOK_SECRET not configured"); 807 - return Response.json({ error: "Webhook secret not configured" }, { status: 500 }); 811 + return Response.json( 812 + { error: "Webhook secret not configured" }, 813 + { status: 500 }, 814 + ); 808 815 } 809 816 810 817 const event = validateEvent(rawBody, headers, webhookSecret); ··· 841 848 customerId, 842 849 status, 843 850 event.data.currentPeriodStart 844 - ? Math.floor(new Date(event.data.currentPeriodStart).getTime() / 1000) 851 + ? Math.floor( 852 + new Date(event.data.currentPeriodStart).getTime() / 853 + 1000, 854 + ) 845 855 : null, 846 856 event.data.currentPeriodEnd 847 - ? Math.floor(new Date(event.data.currentPeriodEnd).getTime() / 1000) 857 + ? Math.floor( 858 + new Date(event.data.currentPeriodEnd).getTime() / 1000, 859 + ) 848 860 : null, 849 861 event.data.cancelAtPeriodEnd ? 1 : 0, 850 862 event.data.canceledAt 851 - ? Math.floor(new Date(event.data.canceledAt).getTime() / 1000) 863 + ? Math.floor( 864 + new Date(event.data.canceledAt).getTime() / 1000, 865 + ) 852 866 : null, 853 867 ], 854 868 ); 855 869 856 - console.log(`[Webhook] Updated subscription ${id} for user ${userId}`); 870 + console.log( 871 + `[Webhook] Updated subscription ${id} for user ${userId}`, 872 + ); 857 873 break; 858 874 } 859 875 ··· 891 907 // Event-driven SSE stream with reconnection support 892 908 const stream = new ReadableStream({ 893 909 async start(controller) { 894 - const encoder = new TextEncoder(); 895 - let isClosed = false; 896 - let lastEventId = Math.floor(Date.now() / 1000); 910 + const encoder = new TextEncoder(); 911 + let isClosed = false; 912 + let lastEventId = Math.floor(Date.now() / 1000); 897 913 898 - const sendEvent = (data: Partial<TranscriptionUpdate>) => { 899 - if (isClosed) return; 900 - try { 901 - // Send event ID for reconnection support 902 - lastEventId = Math.floor(Date.now() / 1000); 903 - controller.enqueue( 904 - encoder.encode( 905 - `id: ${lastEventId}\nevent: update\ndata: ${JSON.stringify(data)}\n\n`, 906 - ), 907 - ); 908 - } catch { 909 - // Controller already closed (client disconnected) 910 - isClosed = true; 911 - } 912 - }; 914 + const sendEvent = (data: Partial<TranscriptionUpdate>) => { 915 + if (isClosed) return; 916 + try { 917 + // Send event ID for reconnection support 918 + lastEventId = Math.floor(Date.now() / 1000); 919 + controller.enqueue( 920 + encoder.encode( 921 + `id: ${lastEventId}\nevent: update\ndata: ${JSON.stringify(data)}\n\n`, 922 + ), 923 + ); 924 + } catch { 925 + // Controller already closed (client disconnected) 926 + isClosed = true; 927 + } 928 + }; 913 929 914 - const sendHeartbeat = () => { 915 - if (isClosed) return; 916 - try { 917 - controller.enqueue(encoder.encode(": heartbeat\n\n")); 918 - } catch { 930 + const sendHeartbeat = () => { 931 + if (isClosed) return; 932 + try { 933 + controller.enqueue(encoder.encode(": heartbeat\n\n")); 934 + } catch { 935 + isClosed = true; 936 + } 937 + }; 938 + // Send initial state from DB and file 939 + const current = db 940 + .query< 941 + { 942 + status: string; 943 + progress: number; 944 + }, 945 + [string] 946 + >("SELECT status, progress FROM transcriptions WHERE id = ?") 947 + .get(transcriptionId); 948 + if (current) { 949 + sendEvent({ 950 + status: current.status as TranscriptionUpdate["status"], 951 + progress: current.progress, 952 + }); 953 + } 954 + // If already complete, close immediately 955 + if ( 956 + current?.status === "completed" || 957 + current?.status === "failed" 958 + ) { 919 959 isClosed = true; 960 + controller.close(); 961 + return; 920 962 } 921 - }; 922 - // Send initial state from DB and file 923 - const current = db 924 - .query< 925 - { 926 - status: string; 927 - progress: number; 928 - }, 929 - [string] 930 - >("SELECT status, progress FROM transcriptions WHERE id = ?") 931 - .get(transcriptionId); 932 - if (current) { 933 - sendEvent({ 934 - status: current.status as TranscriptionUpdate["status"], 935 - progress: current.progress, 936 - }); 937 - } 938 - // If already complete, close immediately 939 - if ( 940 - current?.status === "completed" || 941 - current?.status === "failed" 942 - ) { 943 - isClosed = true; 944 - controller.close(); 945 - return; 946 - } 947 - // Send heartbeats every 2.5 seconds to keep connection alive 948 - const heartbeatInterval = setInterval(sendHeartbeat, 2500); 963 + // Send heartbeats every 2.5 seconds to keep connection alive 964 + const heartbeatInterval = setInterval(sendHeartbeat, 2500); 949 965 950 - // Subscribe to EventEmitter for live updates 951 - const updateHandler = (data: TranscriptionUpdate) => { 952 - if (isClosed) return; 966 + // Subscribe to EventEmitter for live updates 967 + const updateHandler = (data: TranscriptionUpdate) => { 968 + if (isClosed) return; 953 969 954 - // Only send changed fields to save bandwidth 955 - const payload: Partial<TranscriptionUpdate> = { 956 - status: data.status, 957 - progress: data.progress, 958 - }; 970 + // Only send changed fields to save bandwidth 971 + const payload: Partial<TranscriptionUpdate> = { 972 + status: data.status, 973 + progress: data.progress, 974 + }; 959 975 960 - if (data.transcript !== undefined) { 961 - payload.transcript = data.transcript; 962 - } 963 - if (data.error_message !== undefined) { 964 - payload.error_message = data.error_message; 965 - } 976 + if (data.transcript !== undefined) { 977 + payload.transcript = data.transcript; 978 + } 979 + if (data.error_message !== undefined) { 980 + payload.error_message = data.error_message; 981 + } 966 982 967 - sendEvent(payload); 983 + sendEvent(payload); 968 984 969 - // Close stream when done 970 - if (data.status === "completed" || data.status === "failed") { 985 + // Close stream when done 986 + if (data.status === "completed" || data.status === "failed") { 987 + isClosed = true; 988 + clearInterval(heartbeatInterval); 989 + transcriptionEvents.off(transcriptionId, updateHandler); 990 + controller.close(); 991 + } 992 + }; 993 + transcriptionEvents.on(transcriptionId, updateHandler); 994 + // Cleanup on client disconnect 995 + return () => { 971 996 isClosed = true; 972 997 clearInterval(heartbeatInterval); 973 998 transcriptionEvents.off(transcriptionId, updateHandler); 974 - controller.close(); 975 - } 976 - }; 977 - transcriptionEvents.on(transcriptionId, updateHandler); 978 - // Cleanup on client disconnect 979 - return () => { 980 - isClosed = true; 981 - clearInterval(heartbeatInterval); 982 - transcriptionEvents.off(transcriptionId, updateHandler); 983 - }; 984 - }, 985 - }); 986 - return new Response(stream, { 999 + }; 1000 + }, 1001 + }); 1002 + return new Response(stream, { 987 1003 headers: { 988 1004 "Content-Type": "text/event-stream", 989 1005 "Cache-Control": "no-cache", ··· 1457 1473 try { 1458 1474 const { polar } = await import("./lib/polar"); 1459 1475 await polar.subscriptions.revoke({ id: subscriptionId }); 1460 - console.log( 1461 - `[Admin] Revoked subscription ${subscriptionId} for user ${userId}`, 1462 - ); 1463 1476 return Response.json({ 1464 1477 success: true, 1465 1478 message: "Subscription revoked successfully", ··· 1475 1488 error instanceof Error 1476 1489 ? error.message 1477 1490 : "Failed to revoke subscription", 1491 + }, 1492 + { status: 500 }, 1493 + ); 1494 + } 1495 + } catch (error) { 1496 + return handleError(error); 1497 + } 1498 + }, 1499 + PUT: async (req) => { 1500 + try { 1501 + requireAdmin(req); 1502 + const userId = Number.parseInt(req.params.id, 10); 1503 + if (Number.isNaN(userId)) { 1504 + return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1505 + } 1506 + 1507 + try { 1508 + const { polar } = await import("./lib/polar"); 1509 + 1510 + // Get user email 1511 + const user = db 1512 + .query<{ email: string }, [number]>( 1513 + "SELECT email FROM users WHERE id = ?", 1514 + ) 1515 + .get(userId); 1516 + 1517 + if (!user) { 1518 + return Response.json( 1519 + { error: "User not found" }, 1520 + { status: 404 }, 1521 + ); 1522 + } 1523 + 1524 + console.log(`[Admin] Looking for Polar customer with email: ${user.email}`); 1525 + 1526 + // Search for customer by email 1527 + const customers = await polar.customers.list({ 1528 + organizationId: process.env.POLAR_ORGANIZATION_ID, 1529 + query: user.email, 1530 + }); 1531 + 1532 + console.log( 1533 + `[Admin] Found ${customers.result.items?.length || 0} customer(s) matching email`, 1534 + ); 1535 + 1536 + if (!customers.result.items || customers.result.items.length === 0) { 1537 + return Response.json( 1538 + { error: "No Polar customer found with this email" }, 1539 + { status: 404 }, 1540 + ); 1541 + } 1542 + 1543 + const customer = customers.result.items[0]; 1544 + console.log(`[Admin] Customer ID: ${customer.id}`); 1545 + 1546 + // Get all subscriptions for this customer 1547 + const subscriptions = await polar.subscriptions.list({ 1548 + customerId: customer.id, 1549 + }); 1550 + 1551 + console.log( 1552 + `[Admin] Found ${subscriptions.result.items?.length || 0} subscription(s) for customer`, 1553 + ); 1554 + 1555 + if (!subscriptions.result.items || subscriptions.result.items.length === 0) { 1556 + return Response.json( 1557 + { error: "No subscriptions found for this customer" }, 1558 + { status: 404 }, 1559 + ); 1560 + } 1561 + 1562 + // Update each subscription in the database 1563 + for (const subscription of subscriptions.result.items) { 1564 + db.run( 1565 + `INSERT INTO subscriptions (id, user_id, customer_id, status, current_period_start, current_period_end, cancel_at_period_end, canceled_at, updated_at) 1566 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) 1567 + ON CONFLICT(id) DO UPDATE SET 1568 + user_id = excluded.user_id, 1569 + status = excluded.status, 1570 + current_period_start = excluded.current_period_start, 1571 + current_period_end = excluded.current_period_end, 1572 + cancel_at_period_end = excluded.cancel_at_period_end, 1573 + canceled_at = excluded.canceled_at, 1574 + updated_at = excluded.updated_at`, 1575 + [ 1576 + subscription.id, 1577 + userId, 1578 + subscription.customerId, 1579 + subscription.status, 1580 + subscription.currentPeriodStart 1581 + ? Math.floor( 1582 + new Date(subscription.currentPeriodStart).getTime() / 1583 + 1000, 1584 + ) 1585 + : null, 1586 + subscription.currentPeriodEnd 1587 + ? Math.floor( 1588 + new Date(subscription.currentPeriodEnd).getTime() / 1589 + 1000, 1590 + ) 1591 + : null, 1592 + subscription.cancelAtPeriodEnd ? 1 : 0, 1593 + subscription.canceledAt 1594 + ? Math.floor( 1595 + new Date(subscription.canceledAt).getTime() / 1000, 1596 + ) 1597 + : null, 1598 + Math.floor(Date.now() / 1000), 1599 + ], 1600 + ); 1601 + } 1602 + 1603 + console.log( 1604 + `[Admin] Synced ${subscriptions.result.items.length} subscription(s) for user ${userId} (${user.email})`, 1605 + ); 1606 + return Response.json({ 1607 + success: true, 1608 + message: "Subscription synced successfully", 1609 + }); 1610 + } catch (error) { 1611 + console.error( 1612 + `[Admin] Failed to sync subscription for user ${userId}:`, 1613 + error, 1614 + ); 1615 + return Response.json( 1616 + { 1617 + error: 1618 + error instanceof Error 1619 + ? error.message 1620 + : "Failed to sync subscription", 1478 1621 }, 1479 1622 { status: 500 }, 1480 1623 );