🪻 distributed transcription service thistle.dunkirk.sh

feat: add inital polar integration

dunkirk.sh 7bf0148a b978d464

verified
+13
.env.example
···
# Origin - full URL of your app
# Must match exactly where users access your app
# ORIGIN=https://thistle.app
+
+
# Polar.sh payment stuff
+
# Get your access token from https://polar.sh/settings (or sandbox.polar.sh for testing)
+
POLAR_ACCESS_TOKEN=XXX
+
# Get product ID from your Polar dashboard (create a product first)
+
POLAR_PRODUCT_ID=3f1ab9f9-d573-49d4-ac0a-a78bfb06c347
+
# Redirect URL after successful checkout (use {CHECKOUT_ID} placeholder)
+
POLAR_SUCCESS_URL=http://localhost:3000/checkout?checkout_id={CHECKOUT_ID}
+
# Webhook secret for verifying Polar webhook signatures (get from Polar dashboard)
+
POLAR_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxx
+
+
# Environment (set to 'production' in production)
+
NODE_ENV=development
+11
bun.lock
···
"": {
"name": "inky",
"dependencies": {
+
"@polar-sh/sdk": "^0.41.5",
"@simplewebauthn/browser": "^13.2.2",
"@simplewebauthn/server": "^13.2.2",
"eventsource-client": "^1.2.0",
···
"@peculiar/x509": ["@peculiar/x509@1.14.0", "", { "dependencies": { "@peculiar/asn1-cms": "^2.5.0", "@peculiar/asn1-csr": "^2.5.0", "@peculiar/asn1-ecc": "^2.5.0", "@peculiar/asn1-pkcs9": "^2.5.0", "@peculiar/asn1-rsa": "^2.5.0", "@peculiar/asn1-schema": "^2.5.0", "@peculiar/asn1-x509": "^2.5.0", "pvtsutils": "^1.3.6", "reflect-metadata": "^0.2.2", "tslib": "^2.8.1", "tsyringe": "^4.10.0" } }, "sha512-Yc4PDxN3OrxUPiXgU63c+ZRXKGE8YKF2McTciYhUHFtHVB0KMnjeFSU0qpztGhsp4P0uKix4+J2xEpIEDu8oXg=="],
+
"@polar-sh/sdk": ["@polar-sh/sdk@0.41.5", "", { "dependencies": { "standardwebhooks": "^1.0.0", "zod": "^3.25.65 || ^4.0.0" } }, "sha512-E+VoVV+WvebZKmj+KZ/fj1byBZbG7J8hHyzYD9kktvAToigPM19sywo2tFCHeb44aWGCVACMOP8r31e6je7dxA=="],
+
"@simplewebauthn/browser": ["@simplewebauthn/browser@13.2.2", "", {}, "sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA=="],
"@simplewebauthn/server": ["@simplewebauthn/server@13.2.2", "", { "dependencies": { "@hexagon/base64": "^1.1.27", "@levischuck/tiny-cbor": "^0.2.2", "@peculiar/asn1-android": "^2.3.10", "@peculiar/asn1-ecc": "^2.3.8", "@peculiar/asn1-rsa": "^2.3.8", "@peculiar/asn1-schema": "^2.3.8", "@peculiar/asn1-x509": "^2.3.8", "@peculiar/x509": "^1.13.0" } }, "sha512-HcWLW28yTMGXpwE9VLx9J+N2KEUaELadLrkPEEI9tpI5la70xNEVEsu/C+m3u7uoq4FulLqZQhgBCzR9IZhFpA=="],
"@simplewebauthn/types": ["@simplewebauthn/types@12.0.0", "", {}, "sha512-q6y8MkoV8V8jB4zzp18Uyj2I7oFp2/ONL8c3j8uT06AOWu3cIChc1au71QYHrP2b+xDapkGTiv+9lX7xkTlAsA=="],
+
+
"@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="],
"@types/bun": ["@types/bun@1.3.1", "", { "dependencies": { "bun-types": "1.3.1" } }, "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ=="],
···
"eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
+
"fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="],
+
"is-standalone-pwa": ["is-standalone-pwa@0.1.1", "", {}, "sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g=="],
"lit": ["lit@3.3.1", "", { "dependencies": { "@lit/reactive-element": "^2.1.0", "lit-element": "^4.2.0", "lit-html": "^3.3.0" } }, "sha512-Ksr/8L3PTapbdXJCk+EJVB78jDodUMaP54gD24W186zGRARvwrsPfS60wae/SSCTCNZVPd1chXqio1qHQmu4NA=="],
···
"reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="],
+
"standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="],
+
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tsyringe": ["tsyringe@4.10.0", "", { "dependencies": { "tslib": "^1.9.3" } }, "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw=="],
···
"ua-parser-js": ["ua-parser-js@2.0.6", "", { "dependencies": { "detect-europe-js": "^0.1.2", "is-standalone-pwa": "^0.1.1", "ua-is-frozen": "^0.1.2" }, "bin": { "ua-parser-js": "script/cli.js" } }, "sha512-EmaxXfltJaDW75SokrY4/lXMrVyXomE/0FpIIqP2Ctic93gK7rlme55Cwkz8l3YZ6gqf94fCU7AnIkidd/KXPg=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
+
+
"zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="],
"tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
}
+28 -26
package.json
···
{
-
"name": "thistle",
-
"module": "src/index.ts",
-
"type": "module",
-
"private": true,
-
"scripts": {
-
"dev": "bun run src/index.ts --hot",
-
"clean": "rm -rf transcripts uploads thistle.db",
-
"test": "bun test",
-
"test:integration": "bun test src/index.test.ts"
-
},
-
"devDependencies": {
-
"@biomejs/biome": "^2.3.2",
-
"@simplewebauthn/types": "^12.0.0",
-
"@types/bun": "latest"
-
},
-
"peerDependencies": {
-
"typescript": "^5"
-
},
-
"dependencies": {
-
"@simplewebauthn/browser": "^13.2.2",
-
"@simplewebauthn/server": "^13.2.2",
-
"eventsource-client": "^1.2.0",
-
"lit": "^3.3.1",
-
"nanoid": "^5.1.6",
-
"ua-parser-js": "^2.0.6"
-
}
+
"name": "thistle",
+
"module": "src/index.ts",
+
"type": "module",
+
"private": true,
+
"scripts": {
+
"dev": "bun run src/index.ts --hot",
+
"clean": "rm -rf transcripts uploads thistle.db",
+
"test": "bun test",
+
"test:integration": "bun test src/index.test.ts",
+
"ngrok": "ngrok http 3000 --domain casual-renewing-reptile.ngrok-free.app"
+
},
+
"devDependencies": {
+
"@biomejs/biome": "^2.3.2",
+
"@simplewebauthn/types": "^12.0.0",
+
"@types/bun": "latest"
+
},
+
"peerDependencies": {
+
"typescript": "^5"
+
},
+
"dependencies": {
+
"@polar-sh/sdk": "^0.41.5",
+
"@simplewebauthn/browser": "^13.2.2",
+
"@simplewebauthn/server": "^13.2.2",
+
"eventsource-client": "^1.2.0",
+
"lit": "^3.3.1",
+
"nanoid": "^5.1.6",
+
"ua-parser-js": "^2.0.6"
+
}
}
+209 -7
src/components/admin-users.ts
···
transcription_count: number;
last_login: number | null;
created_at: number;
+
subscription_status: string | null;
+
subscription_id: string | null;
}
@customElement("admin-users")
···
@state() isLoading = true;
@state() error: string | null = null;
@state() currentUserEmail: string | null = null;
+
@state() revokingSubscriptions = new Set<number>();
static override styles = css`
:host {
···
opacity: 0.5;
cursor: not-allowed;
}
+
+
.revoke-btn {
+
background: transparent;
+
border: 2px solid var(--accent);
+
color: var(--accent);
+
padding: 0.5rem 1rem;
+
border-radius: 4px;
+
cursor: pointer;
+
font-size: 0.875rem;
+
font-weight: 600;
+
transition: all 0.2s;
+
}
+
+
.revoke-btn:hover:not(:disabled) {
+
background: var(--accent);
+
color: var(--white);
+
}
+
+
.revoke-btn:disabled {
+
opacity: 0.5;
+
cursor: not-allowed;
+
}
+
+
.subscription-badge {
+
background: var(--primary);
+
color: var(--white);
+
padding: 0.25rem 0.5rem;
+
border-radius: 4px;
+
font-size: 0.75rem;
+
font-weight: 600;
+
text-transform: uppercase;
+
}
+
+
.subscription-badge.active {
+
background: var(--primary);
+
color: var(--white);
+
}
+
+
.subscription-badge.none {
+
background: var(--secondary);
+
color: var(--paynes-gray);
+
}
`;
override async connectedCallback() {
···
}
}
-
private async handleDelete(userId: number, email: string) {
+
@state() deleteState: {
+
id: number;
+
type: "user" | "revoke";
+
clicks: number;
+
timeout: number | null;
+
} | null = null;
+
+
private handleDeleteClick(userId: number, email: string, event: Event) {
+
event.stopPropagation();
+
+
// If this is a different item or timeout expired, reset
if (
-
!confirm(
-
`Are you sure you want to delete user ${email}? This will delete all their transcriptions and cannot be undone.`,
-
)
+
!this.deleteState ||
+
this.deleteState.id !== userId ||
+
this.deleteState.type !== "user"
) {
+
// Clear any existing timeout
+
if (this.deleteState?.timeout) {
+
clearTimeout(this.deleteState.timeout);
+
}
+
+
// Set first click
+
const timeout = window.setTimeout(() => {
+
this.deleteState = null;
+
}, 1000);
+
+
this.deleteState = { id: userId, type: "user", clicks: 1, timeout };
return;
}
+
// Increment clicks
+
const newClicks = this.deleteState.clicks + 1;
+
+
// Clear existing timeout
+
if (this.deleteState.timeout) {
+
clearTimeout(this.deleteState.timeout);
+
}
+
+
// Third click - actually delete
+
if (newClicks === 3) {
+
this.deleteState = null;
+
this.performDeleteUser(userId, email);
+
return;
+
}
+
+
// Second click - reset timeout
+
const timeout = window.setTimeout(() => {
+
this.deleteState = null;
+
}, 1000);
+
+
this.deleteState = { id: userId, type: "user", clicks: newClicks, timeout };
+
}
+
+
private async performDeleteUser(userId: number, email: string) {
try {
const response = await fetch(`/api/admin/users/${userId}`, {
method: "DELETE",
···
}
}
+
private handleRevokeClick(userId: number, email: string, subscriptionId: string, event: Event) {
+
event.stopPropagation();
+
+
// If this is a different item or timeout expired, reset
+
if (
+
!this.deleteState ||
+
this.deleteState.id !== userId ||
+
this.deleteState.type !== "revoke"
+
) {
+
// Clear any existing timeout
+
if (this.deleteState?.timeout) {
+
clearTimeout(this.deleteState.timeout);
+
}
+
+
// Set first click
+
const timeout = window.setTimeout(() => {
+
this.deleteState = null;
+
}, 1000);
+
+
this.deleteState = { id: userId, type: "revoke", clicks: 1, timeout };
+
return;
+
}
+
+
// Increment clicks
+
const newClicks = this.deleteState.clicks + 1;
+
+
// Clear existing timeout
+
if (this.deleteState.timeout) {
+
clearTimeout(this.deleteState.timeout);
+
}
+
+
// Third click - actually revoke
+
if (newClicks === 3) {
+
this.deleteState = null;
+
this.performRevokeSubscription(userId, email, subscriptionId);
+
return;
+
}
+
+
// Second click - reset timeout
+
const timeout = window.setTimeout(() => {
+
this.deleteState = null;
+
}, 1000);
+
+
this.deleteState = { id: userId, type: "revoke", clicks: newClicks, timeout };
+
}
+
+
private async performRevokeSubscription(userId: number, email: string, subscriptionId: string) {
+
this.revokingSubscriptions.add(userId);
+
this.requestUpdate();
+
+
try {
+
const response = await fetch(`/api/admin/users/${userId}/subscription`, {
+
method: "DELETE",
+
headers: { "Content-Type": "application/json" },
+
body: JSON.stringify({ subscriptionId }),
+
});
+
+
if (!response.ok) {
+
const data = await response.json();
+
throw new Error(data.error || "Failed to revoke subscription");
+
}
+
+
await this.loadUsers();
+
alert(`Subscription revoked for ${email}`);
+
} catch (error) {
+
console.error("Failed to revoke subscription:", error);
+
alert(`Failed to revoke subscription: ${error instanceof Error ? error.message : "Unknown error"}`);
+
this.revokingSubscriptions.delete(userId);
+
}
+
}
+
+
private getDeleteButtonText(userId: number, type: "user" | "revoke"): string {
+
if (
+
!this.deleteState ||
+
this.deleteState.id !== userId ||
+
this.deleteState.type !== type
+
) {
+
return type === "user" ? "Delete User" : "Revoke Subscription";
+
}
+
+
if (this.deleteState.clicks === 1) {
+
return "Are you sure?";
+
}
+
+
if (this.deleteState.clicks === 2) {
+
return "Final warning!";
+
}
+
+
return type === "user" ? "Delete User" : "Revoke Subscription";
+
}
+
private handleCardClick(userId: number, event: Event) {
-
// Don't open modal if clicking on delete button or role select
+
// Don't open modal if clicking on delete button, revoke button, or role select
if (
(event.target as HTMLElement).closest(".delete-btn") ||
+
(event.target as HTMLElement).closest(".revoke-btn") ||
(event.target as HTMLElement).closest(".role-select")
) {
return;
···
<div class="meta-value">${u.transcription_count}</div>
</div>
<div class="meta-item">
+
<div class="meta-label">Subscription</div>
+
<div class="meta-value">
+
${u.subscription_status
+
? html`<span class="subscription-badge ${u.subscription_status.toLowerCase()}">${u.subscription_status}</span>`
+
: html`<span class="subscription-badge none">None</span>`
+
}
+
</div>
+
</div>
+
<div class="meta-item">
<div class="meta-label">Last Login</div>
<div class="meta-value timestamp">
${this.formatTimestamp(u.last_login)}
···
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
-
<button class="delete-btn" @click=${() => this.handleDelete(u.id, u.email)}>
-
Delete User
+
<button
+
class="revoke-btn"
+
?disabled=${!u.subscription_status || !u.subscription_id || this.revokingSubscriptions.has(u.id)}
+
@click=${(e: Event) => {
+
if (u.subscription_id) {
+
this.handleRevokeClick(u.id, u.email, u.subscription_id, e);
+
}
+
}}
+
>
+
${this.revokingSubscriptions.has(u.id) ? "Revoking..." : this.getDeleteButtonText(u.id, "revoke")}
+
</button>
+
<button class="delete-btn" @click=${(e: Event) => this.handleDeleteClick(u.id, u.email, e)}>
+
${this.getDeleteButtonText(u.id, "user")}
</button>
</div>
</div>
+271
src/components/checkout-success.ts
···
+
import { css, html, LitElement } from "lit";
+
import { customElement, state } from "lit/decorators.js";
+
+
declare global {
+
interface Window {
+
confetti: (options: {
+
particleCount?: number;
+
spread?: number;
+
startVelocity?: number;
+
decay?: number;
+
scalar?: number;
+
origin?: { x?: number; y?: number };
+
}) => void;
+
}
+
}
+
+
@customElement("checkout-success")
+
export class CheckoutSuccess extends LitElement {
+
@state() checkoutId: string | null = null;
+
@state() loading = true;
+
@state() error = "";
+
+
static override styles = css`
+
:host {
+
display: block;
+
max-width: 48rem;
+
margin: 0 auto;
+
padding: 2rem;
+
}
+
+
.success-container {
+
text-align: center;
+
padding: 3rem 2rem;
+
}
+
+
.success-icon {
+
font-size: 5rem;
+
margin-bottom: 1.5rem;
+
animation: bounce 0.6s ease-out;
+
}
+
+
@keyframes bounce {
+
0%, 100% { transform: translateY(0); }
+
50% { transform: translateY(-20px); }
+
}
+
+
h1 {
+
color: var(--text);
+
margin-bottom: 1rem;
+
font-size: 2.5rem;
+
}
+
+
.message {
+
color: var(--text);
+
opacity: 0.8;
+
margin-bottom: 2rem;
+
line-height: 1.8;
+
font-size: 1.125rem;
+
}
+
+
.highlight {
+
color: var(--accent);
+
font-weight: 600;
+
}
+
+
.features {
+
background: var(--background);
+
border: 1px solid var(--secondary);
+
border-radius: 12px;
+
padding: 2rem;
+
margin: 2rem 0;
+
text-align: left;
+
}
+
+
.features h2 {
+
color: var(--text);
+
font-size: 1.25rem;
+
margin: 0 0 1.5rem 0;
+
text-align: center;
+
}
+
+
.feature-list {
+
list-style: none;
+
padding: 0;
+
margin: 0;
+
display: grid;
+
gap: 1rem;
+
}
+
+
.feature-item {
+
display: flex;
+
align-items: center;
+
gap: 0.75rem;
+
color: var(--text);
+
font-size: 1rem;
+
}
+
+
.feature-icon {
+
font-size: 1.5rem;
+
flex-shrink: 0;
+
}
+
+
.checkout-id {
+
background: var(--background);
+
border: 1px solid var(--secondary);
+
border-radius: 8px;
+
padding: 1rem;
+
margin: 2rem 0;
+
font-family: monospace;
+
font-size: 0.875rem;
+
color: var(--text);
+
opacity: 0.6;
+
word-break: break-all;
+
}
+
+
.actions {
+
display: flex;
+
gap: 1rem;
+
justify-content: center;
+
flex-wrap: wrap;
+
margin-top: 2rem;
+
}
+
+
.btn {
+
padding: 0.75rem 1.5rem;
+
border-radius: 6px;
+
font-size: 1rem;
+
font-weight: 500;
+
cursor: pointer;
+
transition: all 0.2s;
+
font-family: inherit;
+
border: 2px solid transparent;
+
text-decoration: none;
+
display: inline-block;
+
}
+
+
.btn-affirmative {
+
background: var(--primary);
+
color: white;
+
border-color: var(--primary);
+
}
+
+
.btn-affirmative:hover {
+
background: var(--gunmetal);
+
border-color: var(--gunmetal);
+
}
+
+
.btn-neutral {
+
background: transparent;
+
color: var(--text);
+
border-color: var(--secondary);
+
}
+
+
.btn-neutral:hover {
+
border-color: var(--primary);
+
color: var(--primary);
+
}
+
+
.error {
+
color: var(--accent);
+
text-align: center;
+
padding: 2rem;
+
}
+
+
.loading {
+
text-align: center;
+
color: var(--text);
+
padding: 2rem;
+
}
+
+
@media (max-width: 768px) {
+
h1 {
+
font-size: 2rem;
+
}
+
+
.message {
+
font-size: 1rem;
+
}
+
}
+
`;
+
+
override connectedCallback() {
+
super.connectedCallback();
+
const params = new URLSearchParams(window.location.search);
+
this.checkoutId = params.get("checkout_id");
+
this.loading = false;
+
+
// Trigger confetti after a short delay
+
setTimeout(() => this.fireConfetti(), 300);
+
}
+
+
fireConfetti() {
+
if (!window.confetti) return;
+
+
const count = 200;
+
const defaults = {
+
origin: { y: 0.7 },
+
};
+
+
const fire = (particleRatio: number, opts: object) => {
+
window.confetti({
+
...defaults,
+
...opts,
+
particleCount: Math.floor(count * particleRatio),
+
});
+
};
+
+
fire(0.25, {
+
spread: 26,
+
startVelocity: 55,
+
});
+
+
fire(0.2, {
+
spread: 60,
+
});
+
+
fire(0.35, {
+
spread: 100,
+
decay: 0.91,
+
scalar: 0.8,
+
});
+
+
fire(0.1, {
+
spread: 120,
+
startVelocity: 25,
+
decay: 0.92,
+
scalar: 1.2,
+
});
+
+
fire(0.1, {
+
spread: 120,
+
startVelocity: 45,
+
});
+
}
+
+
override render() {
+
if (this.loading) {
+
return html`<div class="loading">Loading...</div>`;
+
}
+
+
if (this.error) {
+
return html`<div class="error">${this.error}</div>`;
+
}
+
+
return html`
+
<div class="success-container">
+
<div class="success-icon">🎉</div>
+
<h1>Thanks for purchasing a subscription for thistle!</h1>
+
<p class="message">
+
+
${
+
this.checkoutId
+
? html`
+
<div class="checkout-id">
+
Checkout ID: ${this.checkoutId}
+
</div>
+
`
+
: ""
+
}
+
Go checkout your classes and try recording a lecture!
+
</p>
+
+
<div class="actions">
+
<a href="/classes" class="btn btn-affirmative">
+
Lets go!!!
+
</a>
+
</div>
+
</div>
+
`;
+
}
+
}
+56 -1
src/components/user-settings.ts
···
last_used_at: number | null;
}
-
type SettingsPage = "account" | "sessions" | "passkeys" | "danger";
+
type SettingsPage = "account" | "sessions" | "passkeys" | "billing" | "danger";
@customElement("user-settings")
export class UserSettings extends LitElement {
···
}
}
+
async handleCreateCheckout() {
+
this.loading = true;
+
this.error = "";
+
+
try {
+
const response = await fetch("/api/billing/checkout", {
+
method: "POST",
+
headers: { "Content-Type": "application/json" },
+
});
+
+
if (!response.ok) {
+
const data = await response.json();
+
this.error = data.error || "Failed to create checkout session";
+
return;
+
}
+
+
const { url } = await response.json();
+
window.location.href = url;
+
} catch {
+
this.error = "Failed to create checkout session";
+
} finally {
+
this.loading = false;
+
}
+
}
+
generateRandomAvatar() {
// Generate a random string for the avatar
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
···
`;
+
renderBillingPage() {
+
return html`
+
<div class="content-inner">
+
<div class="section">
+
<h2 class="section-title">Billing & Subscription</h2>
+
<p class="field-description" style="margin-bottom: 1.5rem;">
+
Manage your subscription and billing information.
+
</p>
+
<button
+
class="btn btn-affirmative"
+
@click=${this.handleCreateCheckout}
+
?disabled=${this.loading}
+
>
+
${this.loading ? "Loading..." : "Subscribe to Premium"}
+
</button>
+
${this.error ? html`<p class="error" style="margin-top: 1rem;">${this.error}</p>` : ""}
+
</div>
+
</div>
+
`;
+
}
+
renderDangerPage() {
return html`
<div class="content-inner">
···
Sessions
</button>
<button
+
class="tab ${this.currentPage === "billing" ? "active" : ""}"
+
@click=${() => {
+
this.currentPage = "billing";
+
}}
+
>
+
Billing
+
</button>
+
<button
class="tab ${this.currentPage === "danger" ? "active" : ""}"
@click=${() => {
this.currentPage = "danger";
···
${this.currentPage === "account" ? this.renderAccountPage() : ""}
${this.currentPage === "sessions" ? this.renderSessionsPage() : ""}
+
${this.currentPage === "billing" ? this.renderBillingPage() : ""}
${this.currentPage === "danger" ? this.renderDangerPage() : ""}
</div>
+24
src/db/schema.ts
···
ALTER TABLE class_waitlist DROP COLUMN section;
`,
},
+
{
+
version: 6,
+
name: "Add subscriptions table for Polar integration",
+
sql: `
+
-- Subscriptions table
+
CREATE TABLE IF NOT EXISTS subscriptions (
+
id TEXT PRIMARY KEY,
+
user_id INTEGER NOT NULL,
+
customer_id TEXT NOT NULL,
+
status TEXT NOT NULL,
+
current_period_start INTEGER,
+
current_period_end INTEGER,
+
cancel_at_period_end BOOLEAN DEFAULT 0,
+
canceled_at INTEGER,
+
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
+
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+
);
+
+
CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON subscriptions(user_id);
+
CREATE INDEX IF NOT EXISTS idx_subscriptions_status ON subscriptions(status);
+
CREATE INDEX IF NOT EXISTS idx_subscriptions_customer_id ON subscriptions(customer_id);
+
`,
+
},
];
function getCurrentVersion(): number {
+181
src/index.ts
···
WhisperServiceManager,
} from "./lib/transcription";
import adminHTML from "./pages/admin.html";
+
import checkoutHTML from "./pages/checkout.html";
import classHTML from "./pages/class.html";
import classesHTML from "./pages/classes.html";
import indexHTML from "./pages/index.html";
···
routes: {
"/": indexHTML,
"/admin": adminHTML,
+
"/checkout": checkoutHTML,
"/settings": settingsHTML,
"/transcribe": transcribeHTML,
"/classes": classesHTML,
···
}
},
},
+
"/api/billing/checkout": {
+
POST: async (req) => {
+
const sessionId = getSessionFromRequest(req);
+
if (!sessionId) {
+
return Response.json({ error: "Not authenticated" }, { status: 401 });
+
}
+
const user = getUserBySession(sessionId);
+
if (!user) {
+
return Response.json({ error: "Invalid session" }, { status: 401 });
+
}
+
+
try {
+
const { polar } = await import("./lib/polar");
+
+
const productId = process.env.POLAR_PRODUCT_ID;
+
if (!productId) {
+
return Response.json(
+
{ error: "Product not configured" },
+
{ status: 500 },
+
);
+
}
+
+
const successUrl = process.env.POLAR_SUCCESS_URL;
+
if (!successUrl) {
+
return Response.json(
+
{ error: "Success URL not configured" },
+
{ status: 500 },
+
);
+
}
+
+
const checkout = await polar.checkouts.create({
+
products: [productId],
+
successUrl,
+
customerEmail: user.email,
+
customerName: user.name ?? undefined,
+
metadata: {
+
userId: user.id.toString(),
+
},
+
});
+
+
return Response.json({ url: checkout.url });
+
} catch (error) {
+
console.error("Failed to create checkout:", error);
+
return Response.json(
+
{ error: "Failed to create checkout session" },
+
{ status: 500 },
+
);
+
}
+
},
+
},
+
"/api/webhooks/polar": {
+
POST: async (req) => {
+
try {
+
const { validateEvent } = await import("@polar-sh/sdk/webhooks");
+
+
// Get raw body as string
+
const rawBody = await req.text();
+
const headers = Object.fromEntries(req.headers.entries());
+
+
// Validate webhook signature
+
const webhookSecret = process.env.POLAR_WEBHOOK_SECRET;
+
if (!webhookSecret) {
+
console.error("[Webhook] POLAR_WEBHOOK_SECRET not configured");
+
return Response.json({ error: "Webhook secret not configured" }, { status: 500 });
+
}
+
+
const event = validateEvent(rawBody, headers, webhookSecret);
+
+
console.log(`[Webhook] Received event: ${event.type}`);
+
+
// Handle different event types
+
switch (event.type) {
+
case "subscription.updated": {
+
const { id, status, customerId, metadata } = event.data;
+
const userId = metadata?.userId
+
? Number.parseInt(metadata.userId as string, 10)
+
: null;
+
+
if (!userId) {
+
console.warn("[Webhook] No userId in subscription metadata");
+
break;
+
}
+
+
// Upsert subscription
+
db.run(
+
`INSERT INTO subscriptions (id, user_id, customer_id, status, current_period_start, current_period_end, cancel_at_period_end, canceled_at, updated_at)
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now'))
+
ON CONFLICT(id) DO UPDATE SET
+
status = excluded.status,
+
current_period_start = excluded.current_period_start,
+
current_period_end = excluded.current_period_end,
+
cancel_at_period_end = excluded.cancel_at_period_end,
+
canceled_at = excluded.canceled_at,
+
updated_at = strftime('%s', 'now')`,
+
[
+
id,
+
userId,
+
customerId,
+
status,
+
event.data.currentPeriodStart
+
? Math.floor(new Date(event.data.currentPeriodStart).getTime() / 1000)
+
: null,
+
event.data.currentPeriodEnd
+
? Math.floor(new Date(event.data.currentPeriodEnd).getTime() / 1000)
+
: null,
+
event.data.cancelAtPeriodEnd ? 1 : 0,
+
event.data.canceledAt
+
? Math.floor(new Date(event.data.canceledAt).getTime() / 1000)
+
: null,
+
],
+
);
+
+
console.log(`[Webhook] Updated subscription ${id} for user ${userId}`);
+
break;
+
}
+
+
default:
+
console.log(`[Webhook] Unhandled event type: ${event.type}`);
+
}
+
+
return Response.json({ received: true });
+
} catch (error) {
+
console.error("[Webhook] Error processing webhook:", error);
+
return Response.json(
+
{ error: "Webhook processing failed" },
+
{ status: 400 },
+
);
+
}
+
},
+
},
"/api/transcriptions/:id/stream": {
GET: async (req) => {
const sessionId = getSessionFromRequest(req);
···
updateUserRole(userId, role);
return Response.json({ success: true });
+
} catch (error) {
+
return handleError(error);
+
}
+
},
+
},
+
"/api/admin/users/:id/subscription": {
+
DELETE: async (req) => {
+
try {
+
requireAdmin(req);
+
const userId = Number.parseInt(req.params.id, 10);
+
if (Number.isNaN(userId)) {
+
return Response.json({ error: "Invalid user ID" }, { status: 400 });
+
}
+
+
const body = await req.json();
+
const { subscriptionId } = body as { subscriptionId: string };
+
+
if (!subscriptionId) {
+
return Response.json(
+
{ error: "Subscription ID required" },
+
{ status: 400 },
+
);
+
}
+
+
try {
+
const { polar } = await import("./lib/polar");
+
await polar.subscriptions.revoke({ id: subscriptionId });
+
console.log(
+
`[Admin] Revoked subscription ${subscriptionId} for user ${userId}`,
+
);
+
return Response.json({
+
success: true,
+
message: "Subscription revoked successfully",
+
});
+
} catch (error) {
+
console.error(
+
`[Admin] Failed to revoke subscription ${subscriptionId}:`,
+
error,
+
);
+
return Response.json(
+
{
+
error:
+
error instanceof Error
+
? error.message
+
: "Failed to revoke subscription",
+
},
+
{ status: 500 },
+
);
+
}
} catch (error) {
return handleError(error);
+6 -1
src/lib/auth.ts
···
role: UserRole;
last_login: number | null;
transcription_count: number;
+
subscription_status: string | null;
+
subscription_id: string | null;
}
export function getAllUsersWithStats(): UserWithStats[] {
···
u.created_at,
u.role,
u.last_login,
-
COUNT(t.id) as transcription_count
+
COUNT(DISTINCT t.id) as transcription_count,
+
s.status as subscription_status,
+
s.id as subscription_id
FROM users u
LEFT JOIN transcriptions t ON u.id = t.user_id
+
LEFT JOIN subscriptions s ON u.id = s.user_id AND s.status IN ('active', 'trialing', 'past_due')
GROUP BY u.id
ORDER BY u.created_at DESC`,
)
+8
src/lib/polar.ts
···
+
import { Polar } from "@polar-sh/sdk";
+
+
const isDevelopment = process.env.NODE_ENV !== "production";
+
+
export const polar = new Polar({
+
accessToken: process.env.POLAR_ACCESS_TOKEN ?? "",
+
server: isDevelopment ? "sandbox" : "production",
+
});
+35
src/pages/checkout.html
···
+
<!DOCTYPE html>
+
<html lang="en">
+
+
<head>
+
<meta charset="UTF-8">
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
+
<title>Success! - Thistle</title>
+
<link rel="apple-touch-icon" sizes="180x180" href="../../public/favicon/apple-touch-icon.png">
+
<link rel="icon" type="image/png" sizes="32x32" href="../../public/favicon/favicon-32x32.png">
+
<link rel="icon" type="image/png" sizes="16x16" href="../../public/favicon/favicon-16x16.png">
+
<link rel="manifest" href="../../public/favicon/site.webmanifest">
+
<link rel="stylesheet" href="../styles/main.css">
+
<script src="https://cdn.jsdelivr.net/npm/@tsparticles/confetti@3.0.3/tsparticles.confetti.bundle.min.js"></script>
+
</head>
+
+
<body>
+
<header>
+
<div class="header-content">
+
<a href="/" class="site-title">
+
<img src="../../public/favicon/favicon-32x32.png" alt="Thistle logo">
+
<span>Thistle</span>
+
</a>
+
<auth-component></auth-component>
+
</div>
+
</header>
+
+
<main>
+
<checkout-success></checkout-success>
+
</main>
+
+
<script type="module" src="../components/auth.ts"></script>
+
<script type="module" src="../components/checkout-success.ts"></script>
+
</body>
+
+
</html>
+1
src/styles/main.css
···
--gunmetal: #2d3142ff;
--paynes-gray: #4f5d75ff;
--silver: #bfc0c0ff;
+
--white: #ffffffff;
--off-white: #fcf6f1;
--coral: #ef8354ff;
--success-green: #16a34a;