diff --git a/.cursor/rules/corates.mdc b/.cursor/rules/corates.mdc index b8d9cf951..0e872d0dc 100644 --- a/.cursor/rules/corates.mdc +++ b/.cursor/rules/corates.mdc @@ -16,7 +16,7 @@ Do not worry about migrations either claient side or backend unless specifically ## Coding Standards -- Do not use emojis in code, comments, documentation, or commit messages. +- Do not use emojis in code, comments, documentation, plan files, or commit messages. - For UI icons, use the `solid-icons` library or SVGs only. Do not use emojis. - Follow standard JavaScript/SolidJS/Cloudflare best practices. - Prefer modern ES6+ syntax and features. diff --git a/.cursor/rules/workers.mdc b/.cursor/rules/workers.mdc new file mode 100644 index 000000000..67ac43137 --- /dev/null +++ b/.cursor/rules/workers.mdc @@ -0,0 +1,51 @@ +--- +alwaysApply: true +scope: packages/workers +--- + +# Workers Package Rules + +## Drizzle Transactions + +**ALWAYS use `db.batch()` for multiple related database operations** to ensure atomicity: + +```javascript +// ✅ DO: Use batch for related operations +const batchOps = [ + db.insert(projects).values({ id, name, createdBy }), + db.insert(projectMembers).values({ projectId: id, userId, role: 'owner' }), +]; +await db.batch(batchOps); + +// ❌ DON'T: Separate operations +await db.insert(projects).values({ id, name }); +await db.insert(projectMembers).values({ projectId: id, userId }); +``` + +Use batch when operations must be atomic (all succeed or all fail). Single independent operations don't need batch. + +## Zod Validation + +**ALWAYS validate request bodies** using `validateRequest` middleware: + +```javascript +// ✅ DO: Use middleware +import { validateRequest, projectSchemas } from '../config/validation.js'; + +projectRoutes.post('/', validateRequest(projectSchemas.create), async c => { + const data = c.get('validatedBody'); // Already validated +}); + +// ❌ DON'T: Manual validation +projectRoutes.post('/', async c => { + const body = await c.req.json(); + // Manual checks... +}); +``` + +**Add new schemas to `config/validation.js`** and reuse `commonFields` when possible. Use `validateQueryParams` for query parameters. + +## Examples + +- See `src/routes/account-merge.js` for batch usage +- See `src/routes/projects.js` for validation patterns diff --git a/.gitignore b/.gitignore index 979d92bb7..ffbd2fcda 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. +# IDE specific +.cursor/plans* + # dependencies node_modules .pnp diff --git a/packages/shared/package.json b/packages/shared/package.json index a704b1e06..72e013553 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -14,6 +14,10 @@ "./errors": { "types": "./dist/errors/index.d.ts", "import": "./dist/errors/index.js" + }, + "./plans": { + "types": "./dist/plans/index.d.ts", + "import": "./dist/plans/index.js" } }, "scripts": { diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 25fb742fa..e5a3604f4 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,6 +1,7 @@ /** * Main entry point for @corates/shared package - * Re-exports everything from errors module + * Re-exports everything from errors and plans modules */ export * from './errors/index.js'; +export * from './plans/index.js'; diff --git a/packages/shared/src/plans/index.ts b/packages/shared/src/plans/index.ts new file mode 100644 index 000000000..d8a0e9ce5 --- /dev/null +++ b/packages/shared/src/plans/index.ts @@ -0,0 +1,18 @@ +/** + * Public API exports for plans module + * This is the main entry point for consuming plan configuration + */ + +// Types +export type { + PlanId, + EntitlementKey, + QuotaKey, + Entitlements, + Quotas, + Plan, + Plans, +} from './types.js'; + +// Plan configuration +export { PLANS, DEFAULT_PLAN, getPlan, isUnlimitedQuota } from './plans.js'; diff --git a/packages/shared/src/plans/plans.ts b/packages/shared/src/plans/plans.ts new file mode 100644 index 000000000..9875567cb --- /dev/null +++ b/packages/shared/src/plans/plans.ts @@ -0,0 +1,81 @@ +/** + * Plan configuration + * Maps plans to entitlements (boolean capabilities) and quotas (numeric limits) + * Plans are static configuration - not stored in database + */ + +import type { Plans, PlanId } from './types.js'; + +/** + * Plan configurations for all subscription tiers + */ +export const PLANS: Plans = { + free: { + name: 'Free', + entitlements: { + 'project.create': false, + 'checklist.edit': true, + 'export.pdf': false, + 'ai.run': false, + }, + quotas: { + 'projects.max': 0, + 'storage.project.maxMB': 10, + 'ai.tokens.monthly': 0, + }, + }, + pro: { + name: 'Pro', + entitlements: { + 'project.create': true, + 'checklist.edit': true, + 'export.pdf': true, + 'ai.run': true, + }, + quotas: { + 'projects.max': 10, + 'storage.project.maxMB': 1000, + 'ai.tokens.monthly': 100000, + }, + }, + unlimited: { + name: 'Unlimited', + entitlements: { + 'project.create': true, + 'checklist.edit': true, + 'export.pdf': true, + 'ai.run': true, + }, + quotas: { + 'projects.max': -1, + 'storage.project.maxMB': -1, + 'ai.tokens.monthly': -1, + }, + }, +}; + +/** + * Default plan ID for users without an active subscription + */ +export const DEFAULT_PLAN: PlanId = 'free'; + +/** + * Get plan configuration by plan ID + * @param planId - Plan ID (e.g., 'free', 'pro', 'unlimited') + * @returns Plan configuration, or default plan if planId is invalid + */ +export function getPlan(planId: PlanId | string): Plans[PlanId] { + if (planId in PLANS) { + return PLANS[planId as PlanId]; + } + return PLANS[DEFAULT_PLAN]; +} + +/** + * Check if a quota value represents unlimited quota + * @param quota - Quota value to check + * @returns True if quota is unlimited (value is -1) + */ +export function isUnlimitedQuota(quota: number): boolean { + return quota === -1; +} diff --git a/packages/shared/src/plans/types.ts b/packages/shared/src/plans/types.ts new file mode 100644 index 000000000..264b22b8b --- /dev/null +++ b/packages/shared/src/plans/types.ts @@ -0,0 +1,53 @@ +/** + * Type definitions for plan configuration + * Defines types for plan IDs, entitlement keys, quota keys, and plan structure + */ + +/** + * Plan ID - identifies a subscription tier + */ +export type PlanId = 'free' | 'pro' | 'unlimited'; + +/** + * Entitlement keys - boolean capabilities that can be enabled/disabled per plan + */ +export type EntitlementKey = 'project.create' | 'checklist.edit' | 'export.pdf' | 'ai.run'; + +/** + * Quota keys - numeric limits that can be set per plan + */ +export type QuotaKey = 'projects.max' | 'storage.project.maxMB' | 'ai.tokens.monthly'; + +/** + * Entitlements - mapping of entitlement keys to boolean values + */ +export interface Entitlements { + 'project.create': boolean; + 'checklist.edit': boolean; + 'export.pdf': boolean; + 'ai.run': boolean; +} + +/** + * Quotas - mapping of quota keys to numeric values + * A value of -1 indicates unlimited quota (JSON-safe alternative to Infinity) + */ +export interface Quotas { + 'projects.max': number; + 'storage.project.maxMB': number; + 'ai.tokens.monthly': number; +} + +/** + * Plan configuration - defines entitlements and quotas for a subscription tier + */ +export interface Plan { + name: string; + entitlements: Entitlements; + quotas: Quotas; +} + +/** + * Plans - record of all plan configurations indexed by plan ID + */ +export type Plans = Record; diff --git a/packages/web/package.json b/packages/web/package.json index eef8993b2..91688a682 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -21,7 +21,6 @@ "@solidjs/router": "^0.15.4", "better-auth": "1.4.5", "d3": "^7.9.0", - "nanoid": "^5.1.6", "pdfjs-dist": "^5.4.449", "solid-icons": "^1.1.0", "solid-js": "^1.9.10", diff --git a/packages/web/src/components/admin-ui/UserTable.jsx b/packages/web/src/components/admin-ui/UserTable.jsx index d2e21bd0c..b142d50ea 100644 --- a/packages/web/src/components/admin-ui/UserTable.jsx +++ b/packages/web/src/components/admin-ui/UserTable.jsx @@ -20,8 +20,10 @@ import { impersonateUser, revokeUserSessions, deleteUser, + grantAccess, } from '@/stores/adminStore.js'; import { Avatar, Dialog, Tooltip } from '@corates/ui'; +import { hasActiveAccess } from '@/lib/access.js'; // Provider display info const PROVIDER_INFO = { @@ -36,6 +38,9 @@ export default function UserTable(props) { const [confirmDialog, setConfirmDialog] = createSignal(null); const [banDialog, setBanDialog] = createSignal(null); const [banReason, setBanReason] = createSignal(''); + const [accessDialog, setAccessDialog] = createSignal(null); + const [selectedPlan, setSelectedPlan] = createSignal('free'); + const [accessExpiration, setAccessExpiration] = createSignal(''); const [loading, setLoading] = createSignal(false); const [error, setError] = createSignal(null); @@ -68,6 +73,21 @@ export default function UserTable(props) { return; } + if (action === 'change-access') { + // Initialize with user's current plan or 'free' if no subscription + const currentPlan = user.subscription?.tier || 'free'; + setSelectedPlan(currentPlan); + // Pre-fill expiration if existing + if (user.subscription?.currentPeriodEnd) { + const date = new Date(user.subscription.currentPeriodEnd * 1000); + setAccessExpiration(date.toISOString().slice(0, 16)); // YYYY-MM-DDTHH:MM + } else { + setAccessExpiration(''); + } + setAccessDialog(user); + return; + } + setLoading(true); try { if (action === 'unban') { @@ -133,6 +153,79 @@ export default function UserTable(props) { } }; + const handleChangeAccess = async () => { + const user = accessDialog(); + if (!user) return; + + setLoading(true); + try { + const expiration = accessExpiration(); + let currentPeriodEnd = null; + + if (expiration) { + // Parse date and convert to seconds timestamp + const date = new Date(expiration); + currentPeriodEnd = Math.floor(date.getTime() / 1000); + } + + await grantAccess(user.id, { tier: selectedPlan(), currentPeriodEnd }); + setAccessDialog(null); + setSelectedPlan('free'); + setAccessExpiration(''); + props.onRefresh?.(); + } catch (err) { + const { handleError } = await import('@/lib/error-utils.js'); + await handleError(err, { + setError, + showToast: false, + }); + } finally { + setLoading(false); + } + }; + + const formatAccessStatus = user => { + const subscription = user.subscription; + if (!subscription) return { label: 'Free Plan', color: 'gray', plan: 'free' }; + + const planNames = { free: 'Free', pro: 'Pro', unlimited: 'Unlimited' }; + const planName = planNames[subscription.tier] || subscription.tier || 'Free'; + + if (hasActiveAccess(subscription)) { + if (subscription.currentPeriodEnd) { + const date = new Date(subscription.currentPeriodEnd * 1000); + return { + label: `${planName} (expires ${date.toLocaleDateString()})`, + color: 'green', + plan: subscription.tier, + }; + } + return { label: `${planName} (no expiration)`, color: 'green', plan: subscription.tier }; + } + + if (subscription.status === 'inactive') { + return { label: `${planName} (revoked)`, color: 'red', plan: subscription.tier }; + } + + if (subscription.currentPeriodEnd) { + const date = new Date(subscription.currentPeriodEnd * 1000); + const now = Date.now(); + if (subscription.currentPeriodEnd * 1000 < now) { + return { + label: `${planName} (expired ${date.toLocaleDateString()})`, + color: 'red', + plan: subscription.tier, + }; + } + } + + return { + label: `${planName} (${subscription.status || 'inactive'})`, + color: 'gray', + plan: subscription.tier, + }; + }; + return ( <> {/* Error Toast */} @@ -161,6 +254,9 @@ export default function UserTable(props) { Status + + Access + Joined @@ -174,7 +270,7 @@ export default function UserTable(props) { each={props.users} fallback={ - + No users found @@ -250,6 +346,24 @@ export default function UserTable(props) { + + {(() => { + const access = formatAccessStatus(user); + const colorClasses = { + green: 'bg-green-100 text-green-800', + red: 'bg-red-100 text-red-800', + gray: 'bg-gray-100 text-gray-800', + }; + return ( + + {access.label} + + ); + })()} + {formatDate(user.createdAt)}
@@ -315,6 +429,14 @@ export default function UserTable(props) { Revoke Sessions
+ +
+ {/* Change Access Dialog */} + !open && setAccessDialog(null)} + title='Change Access' + > +
+

+ Change subscription plan for{' '} + + {accessDialog()?.displayName || accessDialog()?.name || accessDialog()?.email} + + . Plan selection determines entitlements and quotas automatically. +

+
+ + +

+ Select the plan tier. Entitlements and quotas are automatically determined by the + plan. +

+
+
+ + setAccessExpiration(e.target.value)} + class='w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none' + /> +

+ Leave empty for permanent access. Subscription will expire at the specified date and + time. +

+
+
+ + +
+
+
+ {/* Confirm Dialog */} props.userId; // Read from store const projects = () => projectStore.getProjectList(); + const projectCount = () => projects()?.length || 0; const isLoading = () => projectStore.isProjectListLoading(); const isLoaded = () => projectStore.isProjectListLoaded(); const error = () => projectStore.getProjectListError(); + // Check both entitlement and quota + const canCreateProject = () => { + return ( + hasEntitlement('project.create') && + hasQuota('projects.max', { used: projectCount(), requested: 1 }) + ); + }; + // Check if error is due to offline state const isOfflineError = () => { const err = error(); @@ -124,15 +136,29 @@ export default function ProjectDashboard(props) {

My Projects

Manage your research projects

- + + {/* Error display */} @@ -162,12 +188,24 @@ export default function ProjectDashboard(props) {
No projects yet
-
+ } > - Create your first project - + +
} diff --git a/packages/web/src/components/project-ui/overview-tab/OverviewTab.jsx b/packages/web/src/components/project-ui/overview-tab/OverviewTab.jsx index 2e9618dbb..df7273cd0 100644 --- a/packages/web/src/components/project-ui/overview-tab/OverviewTab.jsx +++ b/packages/web/src/components/project-ui/overview-tab/OverviewTab.jsx @@ -159,7 +159,13 @@ export default function OverviewTab() {
now; +} + +/** + * Check if access has expired + * @param {Object|null} subscription - Subscription object from API + * @returns {boolean} True if access has expired + */ +export function isAccessExpired(subscription) { + if (!subscription) return true; // No subscription = expired/no access + if (subscription.status !== 'active') return true; + + // If no expiration date, access never expires + if (!subscription.currentPeriodEnd) return false; + + // Check if expiration is in the past + const now = Math.floor(Date.now() / 1000); + return subscription.currentPeriodEnd <= now; +} diff --git a/packages/web/src/lib/entitlements.js b/packages/web/src/lib/entitlements.js new file mode 100644 index 000000000..0bf6fa12f --- /dev/null +++ b/packages/web/src/lib/entitlements.js @@ -0,0 +1,79 @@ +/** + * Entitlement and quota computation (frontend) + * Computes effective entitlements and quotas from subscription at request time + */ + +import { getPlan, DEFAULT_PLAN, isUnlimitedQuota } from '@corates/shared/plans'; + +/** + * Check if subscription is active + * @param {Object|null} subscription - Subscription object from API + * @returns {boolean} True if subscription is active + */ +export function isSubscriptionActive(subscription) { + if (!subscription) return false; + if (subscription.status !== 'active') return false; + if (!subscription.currentPeriodEnd) return true; + const now = Math.floor(Date.now() / 1000); + + const endTime = + typeof subscription.currentPeriodEnd === 'number' ? + subscription.currentPeriodEnd + : parseInt(subscription.currentPeriodEnd); + return endTime > now; +} + +/** + * Get effective entitlements for a user + * @param {Object|null} subscription - Subscription object from API + * @returns {Object} Entitlements object + */ +export function getEffectiveEntitlements(subscription) { + const planId = subscription?.tier || DEFAULT_PLAN; + const plan = getPlan(planId); + if (!isSubscriptionActive(subscription)) { + return getPlan(DEFAULT_PLAN).entitlements; + } + return plan.entitlements; +} + +/** + * Get effective quotas for a user + * @param {Object|null} subscription - Subscription object from API + * @returns {Object} Quotas object + */ +export function getEffectiveQuotas(subscription) { + const planId = subscription?.tier || DEFAULT_PLAN; + const plan = getPlan(planId); + if (!isSubscriptionActive(subscription)) { + return getPlan(DEFAULT_PLAN).quotas; + } + return plan.quotas; +} + +/** + * Check if user has a specific entitlement + * @param {Object|null} subscription - Subscription object from API + * @param {string} entitlement - Entitlement key (e.g., 'project.create') + * @returns {boolean} True if user has the entitlement + */ +export function hasEntitlement(subscription, entitlement) { + const entitlements = getEffectiveEntitlements(subscription); + return entitlements[entitlement] === true; +} + +/** + * Check if user has quota available + * @param {Object|null} subscription - Subscription object from API + * @param {string} quotaKey - Quota key (e.g., 'projects.max') + * @param {Object} options - Options object + * @param {number} options.used - Current usage + * @param {number} [options.requested=1] - Additional amount requested + * @returns {boolean} True if quota allows the request + */ +export function hasQuota(subscription, quotaKey, { used, requested = 1 }) { + const quotas = getEffectiveQuotas(subscription); + const limit = quotas[quotaKey]; + if (isUnlimitedQuota(limit)) return true; + return used + requested <= limit; +} diff --git a/packages/web/src/primitives/__tests__/useProject.test.js b/packages/web/src/primitives/__tests__/useProject.test.js index 8ee45e57d..70284a2cf 100644 --- a/packages/web/src/primitives/__tests__/useProject.test.js +++ b/packages/web/src/primitives/__tests__/useProject.test.js @@ -57,6 +57,7 @@ vi.mock('../useOnlineStatus.js', () => ({ vi.mock('@config/api.js', () => ({ getWsBaseUrl: vi.fn(() => 'ws://localhost:8787'), + LANDING_URL: vi.fn(() => 'https://corates.org'), })); // Mock WebSocket - prevent actual connection attempts diff --git a/packages/web/src/primitives/useProject/pdfs.js b/packages/web/src/primitives/useProject/pdfs.js index 60493f753..bd7946f20 100644 --- a/packages/web/src/primitives/useProject/pdfs.js +++ b/packages/web/src/primitives/useProject/pdfs.js @@ -8,7 +8,6 @@ */ import * as Y from 'yjs'; -import { nanoid } from 'nanoid'; /** * Creates PDF operations @@ -80,7 +79,7 @@ export function createPdfOperations(projectId, getYDoc, isSynced) { clearTag(studyId, tag); } - const pdfId = nanoid(); + const pdfId = crypto.randomUUID(); const pdfYMap = new Y.Map(); pdfYMap.set('id', pdfId); pdfYMap.set('key', pdfInfo.key); diff --git a/packages/web/src/primitives/useSubscription.js b/packages/web/src/primitives/useSubscription.js index 7a4697193..fac586750 100644 --- a/packages/web/src/primitives/useSubscription.js +++ b/packages/web/src/primitives/useSubscription.js @@ -5,29 +5,13 @@ import { createResource, createMemo } from 'solid-js'; import { getSubscription } from '@/api/billing.js'; - -/** - * Tier hierarchy for permission checks - */ -const TIER_LEVELS = { - free: 0, - pro: 1, - team: 2, - enterprise: 3, -}; - -/** - * Feature access by tier - */ -const FEATURE_ACCESS = { - 'unlimited-projects': ['pro', 'team', 'enterprise'], - 'advanced-analytics': ['pro', 'team', 'enterprise'], - 'team-collaboration': ['team', 'enterprise'], - 'priority-support': ['team', 'enterprise'], - sso: ['enterprise'], - 'custom-branding': ['enterprise'], - 'dedicated-support': ['enterprise'], -}; +import { hasActiveAccess as checkActiveAccess } from '@/lib/access.js'; +import { + hasEntitlement as checkEntitlement, + getEffectiveEntitlements, + getEffectiveQuotas, + hasQuota as checkQuota, +} from '@/lib/entitlements.js'; /** * Hook to manage subscription state and permissions @@ -59,51 +43,42 @@ export function useSubscription() { }); /** - * Check if user has minimum tier access - * @param {string} requiredTier - Minimum required tier - * @returns {boolean} - */ - const hasMinimumTier = requiredTier => { - const userLevel = TIER_LEVELS[tier()] ?? 0; - const requiredLevel = TIER_LEVELS[requiredTier] ?? 0; - return userLevel >= requiredLevel; - }; - - /** - * Check if user has access to a specific feature - * @param {string} feature - Feature name - * @returns {boolean} + * Whether the subscription is set to cancel at period end */ - const canAccess = feature => { - const allowedTiers = FEATURE_ACCESS[feature]; - if (!allowedTiers) return true; // Feature not gated - return allowedTiers.includes(tier()); - }; + const willCancel = createMemo(() => subscription()?.cancelAtPeriodEnd ?? false); /** - * Check if user is on Pro tier or higher + * Check if user has active access (time-limited access check) */ - const isPro = createMemo(() => hasMinimumTier('pro')); + const hasActiveAccess = createMemo(() => checkActiveAccess(subscription())); /** - * Check if user is on Team tier or higher + * Effective entitlements for the user */ - const isTeam = createMemo(() => hasMinimumTier('team')); + const entitlements = createMemo(() => getEffectiveEntitlements(subscription())); /** - * Check if user is on Enterprise tier + * Effective quotas for the user */ - const isEnterprise = createMemo(() => tier() === 'enterprise'); + const quotas = createMemo(() => getEffectiveQuotas(subscription())); /** - * Check if user is on free tier + * Check if user has a specific entitlement + * @param {string} entitlement - Entitlement key (e.g., 'project.create') + * @returns {boolean} */ - const isFree = createMemo(() => tier() === 'free'); + const hasEntitlement = entitlement => checkEntitlement(subscription(), entitlement); /** - * Whether the subscription is set to cancel at period end + * Check if user has quota available + * @param {string} quotaKey - Quota key (e.g., 'projects.max') + * @param {Object} options - Options object + * @param {number} options.used - Current usage + * @param {number} [options.requested=1] - Additional amount requested + * @returns {boolean} */ - const willCancel = createMemo(() => subscription()?.cancelAtPeriodEnd ?? false); + const hasQuota = (quotaKey, { used, requested = 1 }) => + checkQuota(subscription(), quotaKey, { used, requested }); /** * Formatted renewal/expiration date @@ -111,7 +86,10 @@ export function useSubscription() { const periodEndDate = createMemo(() => { const endDate = subscription()?.currentPeriodEnd; if (!endDate) return null; - return new Date(endDate).toLocaleDateString('en-US', { + // Handle both seconds and milliseconds timestamps + const timestamp = typeof endDate === 'number' ? endDate : parseInt(endDate); + const date = timestamp > 1000000000000 ? new Date(timestamp) : new Date(timestamp * 1000); + return date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric', @@ -133,12 +111,13 @@ export function useSubscription() { // Permission checks isActive, - hasMinimumTier, - canAccess, - isPro, - isTeam, - isEnterprise, - isFree, + hasActiveAccess, + + // Entitlements and quotas + entitlements, + quotas, + hasEntitlement, + hasQuota, // Subscription details willCancel, diff --git a/packages/web/src/stores/adminStore.js b/packages/web/src/stores/adminStore.js index 6882354e8..8830ae35a 100644 --- a/packages/web/src/stores/adminStore.js +++ b/packages/web/src/stores/adminStore.js @@ -202,6 +202,55 @@ async function deleteUser(userId) { return response.json(); } +/** + * Grant subscription to a user + * @param {string} userId - User ID + * @param {Object} options - Subscription options + * @param {string} options.tier - Plan tier ('free', 'pro', 'unlimited') + * @param {number} [options.currentPeriodEnd] - Expiration timestamp in seconds (optional, null = no expiration) + */ +async function grantAccess(userId, options = {}) { + const { tier, currentPeriodEnd } = options; + if (!tier) { + throw new Error('Tier is required'); + } + const body = { + tier, + status: 'active', + currentPeriodStart: Math.floor(Date.now() / 1000), + }; + if (currentPeriodEnd) { + body.currentPeriodEnd = currentPeriodEnd; + } + + const response = await fetch(`${API_BASE}/api/admin/users/${userId}/subscription`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to grant subscription'); + } + return response.json(); +} + +/** + * Revoke subscription from a user + */ +async function revokeAccess(userId) { + const response = await fetch(`${API_BASE}/api/admin/users/${userId}/subscription`, { + method: 'DELETE', + credentials: 'include', + }); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to revoke subscription'); + } + return response.json(); +} + export { isAdmin, isAdminChecked, @@ -218,4 +267,6 @@ export { stopImpersonation, revokeUserSessions, deleteUser, + grantAccess, + revokeAccess, }; diff --git a/packages/workers/src/config/stripe.js b/packages/workers/src/config/stripe.js index 2a2f4009b..d3d9f9e07 100644 --- a/packages/workers/src/config/stripe.js +++ b/packages/workers/src/config/stripe.js @@ -19,25 +19,6 @@ export const PRICE_IDS = { }, }; -// Tier hierarchy for permission checks -export const TIER_LEVELS = { - free: 0, - pro: 1, - team: 2, - enterprise: 3, -}; - -// Feature access by tier -export const FEATURE_ACCESS = { - 'unlimited-projects': ['pro', 'team', 'enterprise'], - 'advanced-analytics': ['pro', 'team', 'enterprise'], - 'team-collaboration': ['team', 'enterprise'], - 'priority-support': ['team', 'enterprise'], - sso: ['enterprise'], - 'custom-branding': ['enterprise'], - 'dedicated-support': ['enterprise'], -}; - // Tier display names and descriptions export const TIER_INFO = { free: { @@ -58,30 +39,6 @@ export const TIER_INFO = { }, }; -/** - * Check if a tier has access to a minimum required tier - * @param {string} userTier - The user's current tier - * @param {string} requiredTier - The minimum required tier - * @returns {boolean} - */ -export function hasMinimumTier(userTier, requiredTier) { - const userLevel = TIER_LEVELS[userTier] ?? 0; - const requiredLevel = TIER_LEVELS[requiredTier] ?? 0; - return userLevel >= requiredLevel; -} - -/** - * Check if a tier has access to a specific feature - * @param {string} userTier - The user's current tier - * @param {string} feature - The feature to check - * @returns {boolean} - */ -export function hasFeatureAccess(userTier, feature) { - const allowedTiers = FEATURE_ACCESS[feature]; - if (!allowedTiers) return true; // Feature not gated - return allowedTiers.includes(userTier); -} - /** * Get the price ID for a tier and billing interval * @param {string} tier - The subscription tier diff --git a/packages/workers/src/config/validation.js b/packages/workers/src/config/validation.js index ec9eb9f71..0b8756b69 100644 --- a/packages/workers/src/config/validation.js +++ b/packages/workers/src/config/validation.js @@ -108,6 +108,31 @@ export const emailSchemas = { }), }; +/** + * Subscription schemas + */ +export const subscriptionSchemas = { + grant: z.object({ + tier: z.enum(['free', 'pro', 'unlimited'], { + error: "Tier must be one of: 'free', 'pro', 'unlimited'", + }), + status: z.literal('active', { + error: "Status must be 'active'", + }), + currentPeriodStart: z + .number() + .int('Current period start must be an integer timestamp') + .positive('Current period start must be a positive number') + .optional(), + currentPeriodEnd: z + .number() + .int('Current period end must be an integer timestamp') + .positive('Current period end must be a positive number') + .nullable() + .optional(), + }), +}; + /** * Map Zod error to validation error code * @param {object} issue - Zod issue object from error.issues array diff --git a/packages/workers/src/lib/access.js b/packages/workers/src/lib/access.js new file mode 100644 index 000000000..b809e8483 --- /dev/null +++ b/packages/workers/src/lib/access.js @@ -0,0 +1,38 @@ +/** + * Access control helper functions + * Provides utilities for checking time-limited access status + */ + +/** + * Check if a subscription has active access + * @param {Object|null} subscription - Subscription record from database + * @returns {boolean} True if user has active access + */ +export function hasActiveAccess(subscription) { + if (!subscription) return false; + if (subscription.status !== 'active') return false; + + // If no expiration date, access is permanent + if (!subscription.currentPeriodEnd) return true; + + // Check if expiration is in the future (timestamps are in seconds) + const now = Math.floor(Date.now() / 1000); + return subscription.currentPeriodEnd > now; +} + +/** + * Check if access has expired + * @param {Object|null} subscription - Subscription record from database + * @returns {boolean} True if access has expired + */ +export function isAccessExpired(subscription) { + if (!subscription) return true; // No subscription = expired/no access + if (subscription.status !== 'active') return true; + + // If no expiration date, access never expires + if (!subscription.currentPeriodEnd) return false; + + // Check if expiration is in the past + const now = Math.floor(Date.now() / 1000); + return subscription.currentPeriodEnd <= now; +} diff --git a/packages/workers/src/lib/entitlements.js b/packages/workers/src/lib/entitlements.js new file mode 100644 index 000000000..63f986fd4 --- /dev/null +++ b/packages/workers/src/lib/entitlements.js @@ -0,0 +1,86 @@ +/** + * Entitlement and quota computation + * Computes effective entitlements and quotas from subscription at request time + */ + +import { getPlan, DEFAULT_PLAN, isUnlimitedQuota } from '@corates/shared/plans'; + +/** + * Check if subscription is active + * @param {Object|null} subscription - Subscription record from database + * @returns {boolean} True if subscription is active + */ +export function isSubscriptionActive(subscription) { + if (!subscription) return false; + if (subscription.status !== 'active') return false; + if (!subscription.currentPeriodEnd) return true; // No expiration + const now = Math.floor(Date.now() / 1000); + return subscription.currentPeriodEnd > now; +} + +/** + * Get effective entitlements for a user + * Returns entitlements from their plan if subscription is active, otherwise returns free plan entitlements + * @param {Object|null} subscription - Subscription record from database + * @returns {Object} Entitlements object + */ +export function getEffectiveEntitlements(subscription) { + const planId = subscription?.tier || DEFAULT_PLAN; + const plan = getPlan(planId); + + // If subscription is not active, return free plan entitlements + if (!isSubscriptionActive(subscription)) { + return getPlan(DEFAULT_PLAN).entitlements; + } + + return plan.entitlements; +} + +/** + * Get effective quotas for a user + * Returns quotas from their plan if subscription is active, otherwise returns free plan quotas + * @param {Object|null} subscription - Subscription record from database + * @returns {Object} Quotas object + */ +export function getEffectiveQuotas(subscription) { + const planId = subscription?.tier || DEFAULT_PLAN; + const plan = getPlan(planId); + + // If subscription is not active, return free plan quotas + if (!isSubscriptionActive(subscription)) { + return getPlan(DEFAULT_PLAN).quotas; + } + + return plan.quotas; +} + +/** + * Check if user has a specific entitlement + * @param {Object|null} subscription - Subscription record from database + * @param {string} entitlement - Entitlement key (e.g., 'project.create') + * @returns {boolean} True if user has the entitlement + */ +export function hasEntitlement(subscription, entitlement) { + const entitlements = getEffectiveEntitlements(subscription); + return entitlements[entitlement] === true; +} + +/** + * Check if user has quota available + * @param {Object|null} subscription - Subscription record from database + * @param {string} quotaKey - Quota key (e.g., 'projects.max') + * @param {Object} options - Options object + * @param {number} options.used - Current usage + * @param {number} [options.requested=1] - Additional amount requested + * @returns {boolean} True if quota allows the request + */ +export function hasQuota(subscription, quotaKey, { used, requested = 1 }) { + const quotas = getEffectiveQuotas(subscription); + const limit = quotas[quotaKey]; + + // -1 means unlimited + if (isUnlimitedQuota(limit)) return true; + + // Check if usage + requested exceeds limit + return used + requested <= limit; +} diff --git a/packages/workers/src/middleware/requireAccess.js b/packages/workers/src/middleware/requireAccess.js new file mode 100644 index 000000000..de78316d5 --- /dev/null +++ b/packages/workers/src/middleware/requireAccess.js @@ -0,0 +1,54 @@ +/** + * Access control middleware for Hono + * Requires active time-limited access for protected routes + */ + +import { createDb } from '../db/client.js'; +import { getSubscriptionByUserId } from '../db/subscriptions.js'; +import { getAuth } from './auth.js'; +import { hasActiveAccess, isAccessExpired } from '../lib/access.js'; +import { createDomainError, AUTH_ERRORS } from '@corates/shared'; + +/** + * Middleware that requires active access + * Must be used after requireAuth middleware + * @returns {Function} Hono middleware + */ +export function requireAccess() { + return async (c, next) => { + const { user } = getAuth(c); + + if (!user) { + return c.json({ error: 'Authentication required' }, 401); + } + + const db = createDb(c.env.DB); + const subscription = await getSubscriptionByUserId(db, user.id); + + // Check if user has active access + if (!hasActiveAccess(subscription)) { + const error = createDomainError( + AUTH_ERRORS.FORBIDDEN, + { reason: 'project_creation' }, + subscription && isAccessExpired(subscription) ? + 'Your access has expired. Please contact support to renew your access.' + : 'Active access is required to create projects. Please contact support to request access.', + ); + + // Add expiration info if available + if (subscription?.currentPeriodEnd) { + error.details = { + expiredAt: subscription.currentPeriodEnd, + expiredAtFormatted: new Date(subscription.currentPeriodEnd * 1000).toISOString(), + }; + } + + return c.json(error, error.statusCode); + } + + // Attach subscription to context for downstream use + c.set('subscription', subscription); + + await next(); + }; +} diff --git a/packages/workers/src/middleware/requireEntitlement.js b/packages/workers/src/middleware/requireEntitlement.js new file mode 100644 index 000000000..d06ceeb23 --- /dev/null +++ b/packages/workers/src/middleware/requireEntitlement.js @@ -0,0 +1,44 @@ +/** + * Entitlement middleware for Hono + * Requires a specific entitlement for protected routes + */ + +import { createDb } from '../db/client.js'; +import { getSubscriptionByUserId } from '../db/subscriptions.js'; +import { getAuth } from './auth.js'; +import { hasEntitlement, getEffectiveEntitlements } from '../lib/entitlements.js'; +import { createDomainError, AUTH_ERRORS } from '@corates/shared'; + +/** + * Middleware that requires a specific entitlement + * Must be used after requireAuth middleware + * @param {string} entitlement - Entitlement key (e.g., 'project.create') + * @returns {Function} Hono middleware + */ +export function requireEntitlement(entitlement) { + return async (c, next) => { + const { user } = getAuth(c); + + if (!user) { + return c.json({ error: 'Authentication required' }, 401); + } + + const db = createDb(c.env.DB); + const subscription = await getSubscriptionByUserId(db, user.id); + + if (!hasEntitlement(subscription, entitlement)) { + const error = createDomainError( + AUTH_ERRORS.FORBIDDEN, + { reason: 'missing_entitlement', entitlement }, + `This feature requires the '${entitlement}' entitlement. Please upgrade your plan.`, + ); + return c.json(error, error.statusCode); + } + + // Attach subscription and entitlements to context + c.set('subscription', subscription); + c.set('entitlements', getEffectiveEntitlements(subscription)); + + await next(); + }; +} diff --git a/packages/workers/src/middleware/requireQuota.js b/packages/workers/src/middleware/requireQuota.js new file mode 100644 index 000000000..ce4bcfe2b --- /dev/null +++ b/packages/workers/src/middleware/requireQuota.js @@ -0,0 +1,52 @@ +/** + * Quota middleware for Hono + * Requires quota availability for protected routes + */ + +import { createDb } from '../db/client.js'; +import { getSubscriptionByUserId } from '../db/subscriptions.js'; +import { getAuth } from './auth.js'; +import { hasQuota, getEffectiveQuotas } from '../lib/entitlements.js'; +import { createDomainError, AUTH_ERRORS } from '@corates/shared'; +import { isUnlimitedQuota } from '@corates/shared/plans'; + +/** + * Middleware that requires quota availability + * Must be used after requireAuth middleware + * @param {string} quotaKey - Quota key (e.g., 'projects.max') + * @param {Function} getUsage - Async function that returns current usage: (c, user) => Promise + * @param {number} [requested=1] - Amount requested (default: 1) + * @returns {Function} Hono middleware + */ +export function requireQuota(quotaKey, getUsage, requested = 1) { + return async (c, next) => { + const { user } = getAuth(c); + + if (!user) { + return c.json({ error: 'Authentication required' }, 401); + } + + const db = createDb(c.env.DB); + const subscription = await getSubscriptionByUserId(db, user.id); + + // Get current usage + const used = await getUsage(c, user); + + if (!hasQuota(subscription, quotaKey, { used, requested })) { + const quotas = getEffectiveQuotas(subscription); + const limit = quotas[quotaKey]; + const error = createDomainError( + AUTH_ERRORS.FORBIDDEN, + { reason: 'quota_exceeded', quotaKey, used, limit, requested }, + `Quota exceeded: ${quotaKey}. Current usage: ${used}, Limit: ${isUnlimitedQuota(limit) ? 'unlimited' : limit}, Requested: ${requested}`, + ); + return c.json(error, error.statusCode); + } + + // Attach subscription and quotas to context + c.set('subscription', subscription); + c.set('quotas', getEffectiveQuotas(subscription)); + + await next(); + }; +} diff --git a/packages/workers/src/middleware/subscription.js b/packages/workers/src/middleware/subscription.js index afbeefd93..67880107f 100644 --- a/packages/workers/src/middleware/subscription.js +++ b/packages/workers/src/middleware/subscription.js @@ -2,113 +2,7 @@ * Subscription middleware for Hono * Provides tier-based access control for protected routes */ - -import { createDb } from '../db/client.js'; -import { getSubscriptionByUserId } from '../db/subscriptions.js'; -import { hasMinimumTier, hasFeatureAccess } from '../config/stripe.js'; -import { getAuth } from './auth.js'; -import { DEFAULT_SUBSCRIPTION_TIER, ACTIVE_STATUSES } from '../config/constants.js'; - -/** - * Middleware that requires a minimum subscription tier - * Must be used after requireAuth middleware - * @param {string} minTier - Minimum required tier ('free', 'pro', 'team', 'enterprise') - * @returns {Function} Hono middleware - */ -export function requireTier(minTier) { - return async (c, next) => { - const { user } = getAuth(c); - - if (!user) { - return c.json({ error: 'Authentication required' }, 401); - } - - const db = createDb(c.env.DB); - const subscription = await getSubscriptionByUserId(db, user.id); - const userTier = subscription?.tier ?? DEFAULT_SUBSCRIPTION_TIER; - - // Check if subscription is active - if (subscription && !ACTIVE_STATUSES.includes(subscription.status)) { - return c.json( - { - error: 'Subscription inactive', - status: subscription.status, - message: 'Please update your payment method or reactivate your subscription', - }, - 403, - ); - } - - // Check tier level - if (!hasMinimumTier(userTier, minTier)) { - return c.json( - { - error: 'Upgrade required', - currentTier: userTier, - requiredTier: minTier, - message: `This feature requires a ${minTier} subscription or higher`, - }, - 403, - ); - } - - // Attach subscription to context for downstream use - c.set('subscription', subscription); - c.set('tier', userTier); - - await next(); - }; -} - -/** - * Middleware that requires access to a specific feature - * Must be used after requireAuth middleware - * @param {string} feature - Feature name to check - * @returns {Function} Hono middleware - */ -export function requireFeature(feature) { - return async (c, next) => { - const { user } = getAuth(c); - - if (!user) { - return c.json({ error: 'Authentication required' }, 401); - } - - const db = createDb(c.env.DB); - const subscription = await getSubscriptionByUserId(db, user.id); - const userTier = subscription?.tier ?? DEFAULT_SUBSCRIPTION_TIER; - - // Check if subscription is active - if (subscription && !ACTIVE_STATUSES.includes(subscription.status)) { - return c.json( - { - error: 'Subscription inactive', - status: subscription.status, - }, - 403, - ); - } - - // Check feature access - if (!hasFeatureAccess(userTier, feature)) { - return c.json( - { - error: 'Feature not available', - feature, - currentTier: userTier, - message: `This feature is not available on your current plan`, - }, - 403, - ); - } - - // Attach subscription to context - c.set('subscription', subscription); - c.set('tier', userTier); - - await next(); - }; -} +import { DEFAULT_SUBSCRIPTION_TIER } from '../config/constants.js'; /** * Get the subscription from context (after middleware has run) diff --git a/packages/workers/src/routes/__tests__/projects.test.js b/packages/workers/src/routes/__tests__/projects.test.js index 31787c94b..5b2e786a5 100644 --- a/packages/workers/src/routes/__tests__/projects.test.js +++ b/packages/workers/src/routes/__tests__/projects.test.js @@ -11,6 +11,7 @@ import { seedUser, seedProject, seedProjectMember, + seedSubscription, json, } from '../../__tests__/helpers.js'; @@ -178,6 +179,15 @@ describe('Project Routes - POST /api/projects', () => { updatedAt: nowSec, }); + await seedSubscription({ + id: 'sub-1', + userId: 'user-1', + tier: 'pro', + status: 'active', + createdAt: nowSec, + updatedAt: nowSec, + }); + const res = await fetchProjects('/api/projects', { method: 'POST', headers: { 'content-type': 'application/json' }, @@ -222,6 +232,15 @@ describe('Project Routes - POST /api/projects', () => { updatedAt: nowSec, }); + await seedSubscription({ + id: 'sub-1', + userId: 'user-1', + tier: 'pro', + status: 'active', + createdAt: nowSec, + updatedAt: nowSec, + }); + const res = await fetchProjects('/api/projects', { method: 'POST', headers: { 'content-type': 'application/json' }, @@ -248,6 +267,15 @@ describe('Project Routes - POST /api/projects', () => { updatedAt: nowSec, }); + await seedSubscription({ + id: 'sub-1', + userId: 'user-1', + tier: 'pro', + status: 'active', + createdAt: nowSec, + updatedAt: nowSec, + }); + const res = await fetchProjects('/api/projects', { method: 'POST', headers: { 'content-type': 'application/json' }, @@ -273,6 +301,15 @@ describe('Project Routes - POST /api/projects', () => { updatedAt: nowSec, }); + await seedSubscription({ + id: 'sub-1', + userId: 'user-1', + tier: 'pro', + status: 'active', + createdAt: nowSec, + updatedAt: nowSec, + }); + const res = await fetchProjects('/api/projects', { method: 'POST', headers: { 'content-type': 'application/json' }, diff --git a/packages/workers/src/routes/admin.js b/packages/workers/src/routes/admin.js index 716e2f37a..f37512fa8 100644 --- a/packages/workers/src/routes/admin.js +++ b/packages/workers/src/routes/admin.js @@ -26,6 +26,9 @@ import { USER_ERRORS, SYSTEM_ERRORS, } from '@corates/shared'; +import { upsertSubscription, getSubscriptionByUserId } from '../db/subscriptions.js'; +import { getPlan } from '@corates/shared/plans'; +import { subscriptionSchemas, validateRequest } from '../config/validation.js'; const adminRoutes = new Hono(); // Apply admin middleware to all routes @@ -138,23 +141,35 @@ adminRoutes.get('/users', async c => { // Get paginated results const users = await query.orderBy(desc(user.createdAt)).limit(limit).offset(offset); - // Fetch linked accounts for all users in the result set + // Fetch linked accounts and subscriptions for all users in the result set const userIds = users.map(u => u.id); let accountsMap = {}; + let subscriptionsMap = {}; if (userIds.length > 0) { - const accounts = await db - .select({ - userId: account.userId, - providerId: account.providerId, - }) - .from(account) - .where( - sql`${account.userId} IN (${sql.join( - userIds.map(id => sql`${id}`), - sql`, `, - )})`, - ); + const [accounts, userSubscriptions] = await Promise.all([ + db + .select({ + userId: account.userId, + providerId: account.providerId, + }) + .from(account) + .where( + sql`${account.userId} IN (${sql.join( + userIds.map(id => sql`${id}`), + sql`, `, + )})`, + ), + db + .select() + .from(subscriptions) + .where( + sql`${subscriptions.userId} IN (${sql.join( + userIds.map(id => sql`${id}`), + sql`, `, + )})`, + ), + ]); // Group accounts by userId accountsMap = accounts.reduce((acc, a) => { @@ -162,12 +177,19 @@ adminRoutes.get('/users', async c => { acc[a.userId].push(a.providerId); return acc; }, {}); + + // Group subscriptions by userId + subscriptionsMap = userSubscriptions.reduce((acc, s) => { + acc[s.userId] = s; + return acc; + }, {}); } - // Merge providers into user objects + // Merge providers and subscriptions into user objects const usersWithProviders = users.map(u => ({ ...u, providers: accountsMap[u.id] || [], + subscription: subscriptionsMap[u.id] || null, })); return c.json({ @@ -242,11 +264,19 @@ adminRoutes.get('/users/:userId', async c => { .where(eq(account.userId, userId)) .orderBy(desc(account.createdAt)); + // Get user's subscription/access + const [userSubscription] = await db + .select() + .from(subscriptions) + .where(eq(subscriptions.userId, userId)) + .limit(1); + return c.json({ user: userData, projects: userProjects, sessions: userSessions, accounts: linkedAccounts, + subscription: userSubscription || null, }); } catch (error) { console.error('Error fetching user details:', error); @@ -497,4 +527,126 @@ adminRoutes.get('/check', async c => { }); }); +/** + * POST /api/admin/users/:userId/subscription + * Grant or update subscription for a user + * Body: { tier: 'free'|'pro'|'unlimited', status: 'active', currentPeriodStart?: timestamp, currentPeriodEnd?: timestamp } + * Admins select the plan (tier), which determines entitlements/quotas via configuration + */ +adminRoutes.post( + '/users/:userId/subscription', + validateRequest(subscriptionSchemas.grant), + async c => { + const userId = c.req.param('userId'); + const db = createDb(c.env.DB); + + try { + const { tier, currentPeriodStart, currentPeriodEnd } = c.get('validatedBody'); + // status is validated by Zod schema to be 'active', so we can use it directly + + // Validate user exists + const [userData] = await db.select().from(user).where(eq(user.id, userId)).limit(1); + if (!userData) { + const error = createDomainError(USER_ERRORS.NOT_FOUND, { userId }); + return c.json(error, error.statusCode); + } + + // Convert timestamps if provided (expecting seconds, but handle both) + let periodStart = currentPeriodStart; + let periodEnd = currentPeriodEnd; + + if (periodStart && typeof periodStart === 'number') { + // If timestamp is in milliseconds, convert to seconds + if (periodStart > 1000000000000) { + periodStart = Math.floor(periodStart / 1000); + } + } + + if (periodEnd !== null && periodEnd !== undefined && typeof periodEnd === 'number') { + // If timestamp is in milliseconds, convert to seconds + if (periodEnd > 1000000000000) { + periodEnd = Math.floor(periodEnd / 1000); + } + } + + // Create or update subscription + const subscriptionId = crypto.randomUUID(); + const subscription = await upsertSubscription(db, { + id: subscriptionId, + userId, + tier, + status: 'active', + currentPeriodStart: periodStart ? new Date(periodStart * 1000) : new Date(), + currentPeriodEnd: periodEnd ? new Date(periodEnd * 1000) : null, + }); + + // Get plan info for response + const plan = getPlan(tier); + + return c.json({ + success: true, + message: 'Subscription granted successfully', + subscription, + plan: { + id: tier, + name: plan.name, + entitlements: plan.entitlements, + quotas: plan.quotas, + }, + }); + } catch (error) { + console.error('Error granting access:', error); + const dbError = createDomainError(SYSTEM_ERRORS.DB_ERROR, { + operation: 'grant_access', + originalError: error.message, + }); + return c.json(dbError, dbError.statusCode); + } + }, +); + +/** + * DELETE /api/admin/users/:userId/subscription + * Revoke subscription for a user (sets status to inactive) + */ +adminRoutes.delete('/users/:userId/subscription', async c => { + const userId = c.req.param('userId'); + const db = createDb(c.env.DB); + + try { + // Validate user exists + const [userData] = await db.select().from(user).where(eq(user.id, userId)).limit(1); + if (!userData) { + const error = createDomainError(USER_ERRORS.NOT_FOUND, { userId }); + return c.json(error, error.statusCode); + } + + // Get existing subscription + const existing = await getSubscriptionByUserId(db, userId); + + if (existing) { + // Set status to inactive instead of deleting (preserves history) + await db + .update(subscriptions) + .set({ + status: 'inactive', + updatedAt: new Date(), + }) + .where(eq(subscriptions.userId, userId)); + } + + return c.json({ + success: true, + message: 'Subscription revoked successfully', + }); + } catch (error) { + console.error('Error revoking access:', error); + const dbError = createDomainError(SYSTEM_ERRORS.DB_ERROR, { + operation: 'revoke_access', + originalError: error.message, + }); + return c.json(dbError, dbError.statusCode); + } +}); + export { adminRoutes }; diff --git a/packages/workers/src/routes/projects.js b/packages/workers/src/routes/projects.js index 4c226c4ab..4ca72307f 100644 --- a/packages/workers/src/routes/projects.js +++ b/packages/workers/src/routes/projects.js @@ -6,8 +6,10 @@ import { Hono } from 'hono'; import { createDb } from '../db/client.js'; import { projects, projectMembers, user } from '../db/schema.js'; -import { eq, and } from 'drizzle-orm'; +import { eq, and, count } from 'drizzle-orm'; import { requireAuth, getAuth } from '../middleware/auth.js'; +import { requireEntitlement } from '../middleware/requireEntitlement.js'; +import { requireQuota } from '../middleware/requireQuota.js'; import { projectSchemas, validateRequest } from '../config/validation.js'; import { EDIT_ROLES } from '../config/constants.js'; import { createDomainError, PROJECT_ERRORS, AUTH_ERRORS, SYSTEM_ERRORS } from '@corates/shared'; @@ -82,99 +84,114 @@ projectRoutes.get('/:id', async c => { }); /** - * POST /api/projects - * Create a new project + * Helper to get current project count for user + * Used for quota checking */ -projectRoutes.post('/', validateRequest(projectSchemas.create), async c => { - const { user: authUser } = getAuth(c); +async function getProjectCount(c, user) { const db = createDb(c.env.DB); - const { name, description } = c.get('validatedBody'); - - const projectId = crypto.randomUUID(); - const now = new Date(); - - try { - // Use D1 batch for transaction-like behavior - // D1 doesn't support true transactions, but batch ensures atomicity - const statements = [ - c.env.DB.prepare( - 'INSERT INTO projects (id, name, description, createdBy, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?)', - ).bind( - projectId, - name, - description, - authUser.id, - Math.floor(now.getTime() / 1000), - Math.floor(now.getTime() / 1000), - ), - c.env.DB.prepare( - 'INSERT INTO project_members (id, projectId, userId, role, joinedAt) VALUES (?, ?, ?, ?, ?)', - ).bind( - crypto.randomUUID(), - projectId, - authUser.id, - 'owner', - Math.floor(now.getTime() / 1000), - ), - ]; - - await c.env.DB.batch(statements); + const [result] = await db + .select({ count: count() }) + .from(projects) + .where(eq(projects.createdBy, user.id)); + return result?.count || 0; +} - // Get creator's user info for DO sync - const creator = await db - .select({ - id: user.id, - name: user.name, - email: user.email, - displayName: user.displayName, - image: user.image, - }) - .from(user) - .where(eq(user.id, authUser.id)) - .get(); +/** + * POST /api/projects + * Create a new project + * Requires 'project.create' entitlement and 'projects.max' quota + */ +projectRoutes.post( + '/', + requireEntitlement('project.create'), + requireQuota('projects.max', getProjectCount, 1), + validateRequest(projectSchemas.create), + async c => { + const { user: authUser } = getAuth(c); + const db = createDb(c.env.DB); + const { name, description } = c.get('validatedBody'); + + const projectId = crypto.randomUUID(); + const memberId = crypto.randomUUID(); + const now = new Date(); - // Sync to Durable Object - await syncProjectToDO( - c.env, - projectId, - { - name: name.trim(), - description: description?.trim() || null, - createdAt: now.getTime(), - updatedAt: now.getTime(), - }, - [ - { + try { + // Use Drizzle batch for transaction-like behavior + // D1 doesn't support true transactions, but batch ensures atomicity + await db.batch([ + db.insert(projects).values({ + id: projectId, + name: name.trim(), + description: description?.trim() || null, + createdBy: authUser.id, + createdAt: now, + updatedAt: now, + }), + db.insert(projectMembers).values({ + id: memberId, + projectId, userId: authUser.id, role: 'owner', - joinedAt: now.getTime(), - name: creator?.name || null, - email: creator?.email || null, - displayName: creator?.displayName || null, - image: creator?.image || null, + joinedAt: now, + }), + ]); + + // Get creator's user info for DO sync + const creator = await db + .select({ + id: user.id, + name: user.name, + email: user.email, + displayName: user.displayName, + image: user.image, + }) + .from(user) + .where(eq(user.id, authUser.id)) + .get(); + + // Sync to Durable Object + await syncProjectToDO( + c.env, + projectId, + { + name: name.trim(), + description: description?.trim() || null, + createdAt: now.getTime(), + updatedAt: now.getTime(), }, - ], - ); - - const newProject = { - id: projectId, - name, - description, - role: 'owner', - createdAt: now, - updatedAt: now, - }; + [ + { + userId: authUser.id, + role: 'owner', + joinedAt: now.getTime(), + name: creator?.name || null, + email: creator?.email || null, + displayName: creator?.displayName || null, + image: creator?.image || null, + }, + ], + ); - return c.json(newProject, 201); - } catch (error) { - console.error('Error creating project:', error); - const dbError = createDomainError(SYSTEM_ERRORS.DB_TRANSACTION_FAILED, { - operation: 'create_project', - originalError: error.message, - }); - return c.json(dbError, dbError.statusCode); - } -}); + const newProject = { + id: projectId, + name, + description, + role: 'owner', + createdAt: now, + updatedAt: now, + }; + + return c.json(newProject, 201); + } catch (error) { + console.error('Error creating project:', error); + const dbError = createDomainError(SYSTEM_ERRORS.DB_TRANSACTION_FAILED, { + operation: 'create_project', + originalError: error.message, + }); + return c.json(dbError, dbError.statusCode); + } + }, +); /** * PUT /api/projects/:id diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0c5905e8b..90f4405c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -233,9 +233,6 @@ importers: d3: specifier: ^7.9.0 version: 7.9.0 - nanoid: - specifier: ^5.1.6 - version: 5.1.6 pdfjs-dist: specifier: ^5.4.449 version: 5.4.449 @@ -7573,14 +7570,6 @@ packages: engines: { node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1 } hasBin: true - nanoid@5.1.6: - resolution: - { - integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==, - } - engines: { node: ^18 || >=20 } - hasBin: true - nanostores@1.1.0: resolution: { @@ -14500,8 +14489,6 @@ snapshots: nanoid@3.3.11: {} - nanoid@5.1.6: {} - nanostores@1.1.0: {} natural-compare@1.4.0: {}