feat: add demo app feature for non-technical onboarding#1480
Conversation
|
Note Other AI code review bot(s) detectedCodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review. 📝 WalkthroughWalkthroughAdds a demo-app onboarding flow: new localized strings, a frontend demo option and createDemoApp call, a serverless /app/demo endpoint that provisions demo apps (apps/versions/channels/devices/metrics/builds), DB migration to auto-clean demo apps, and trigger/stats changes to treat demo apps specially. Changes
Sequence Diagram(s)sequenceDiagram
actor User
participant Frontend as StepsApp.vue
participant API as /app/demo (serverless)
participant DB as Database
participant Trigger as on_app_create / on_version_create
participant Dashboard as Dashboard (Usage)
User->>Frontend: Click "Create demo app"
Frontend->>API: POST /app/demo { owner_org }
API->>DB: Insert app, versions, channels, devices, metrics, build_requests
DB-->>API: OK { app_id }
API->>DB: Invalidate app_metrics_cache
API-->>Frontend: { status: success, app_id }
Note over DB,Trigger: on_app_create fires with is_demo=true
Trigger->>Trigger: Skip onboarding emails/store-info and notification flows
Frontend->>Frontend: advance onboarding step
Frontend->>Dashboard: Navigate to /app/{app_id}?refresh=true
Dashboard->>DB: Full reload (forces fresh data)
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Suggested labels
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 SQLFluff (4.0.0)supabase/migrations/20260121000000_add_demo_app_support.sqlUser Error: No dialect was specified. You must configure a dialect or specify one on the command line using --dialect after the command. Available dialects: Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 79ef2119c6
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| app.post('/demo', middlewareKey(['all', 'write']), async (c) => { | ||
| const body = await getBodyOrQuery<CreateDemoApp>(c) | ||
| const apikey = c.get('apikey') as Database['public']['Tables']['apikeys']['Row'] | ||
| return createDemoApp(c, body, apikey) |
There was a problem hiding this comment.
Allow JWT auth for demo app creation endpoint
The new /app/demo handler is guarded with middlewareKey, which only validates API keys, but the onboarding UI calls supabase.functions.invoke('app/demo') from the browser and supplies a user JWT (typically as Authorization: Bearer ...) rather than a capgkey. middlewareKey treats that header as an API key and will reject it, so normal users will get 401s and cannot create demo apps. Consider switching this route to middlewareV2/middlewareAuth or explicitly passing an API key from the client.
Useful? React with 👍 / 👎.
| -- Delete the app (cascades will handle related tables) | ||
| DELETE FROM public.apps WHERE app_id = app_record.app_id; |
There was a problem hiding this comment.
Avoid orphaned data when deleting expired demo apps
The cleanup function deletes only from public.apps and relies on cascades, but app deletion elsewhere explicitly removes related rows (devices, stats, bandwidth/storage usage, daily_* metrics, etc.) that do not have FK cascades. Deleting demo apps directly will therefore leave orphaned records and stale metrics/storage data for expired demos. Consider reusing the same cleanup logic as deleteApp (or add the missing table deletions) to keep data consistent.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Pull request overview
This PR adds a demo app creation feature to help non-technical users explore Capgo during onboarding without requiring CLI setup. Demo apps are automatically deleted after 14 days via a cron job.
Changes:
- Added database schema for demo apps with
is_demoflag anddemo_expires_attimestamp, plus automated cleanup via cron job - Created backend endpoint
/app/demofor creating demo apps with proper authorization checks - Modified
on_app_createtrigger to skip onboarding emails and store info for demo apps - Added UI option in onboarding flow to create demo apps as an alternative to CLI setup
- Added translations for demo app feature in 11 languages
Reviewed changes
Copilot reviewed 22 out of 22 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| supabase/migrations/20260121000000_add_demo_app_support.sql | Adds database columns, cleanup function, and cron task for demo app auto-deletion |
| supabase/functions/_backend/triggers/on_app_create.ts | Detects demo apps and skips onboarding emails/store info |
| supabase/functions/_backend/public/app/index.ts | Registers new /demo endpoint with proper middleware |
| supabase/functions/_backend/public/app/demo.ts | Implements demo app creation logic with authorization |
| src/components/dashboard/StepsApp.vue | Adds demo app creation button to onboarding UI |
| messages/*.json | Adds translations for demo app UI text in 11 languages |
| -- Get total count of deleted apps | ||
| SELECT COUNT(*) INTO deleted_count | ||
| FROM public.apps | ||
| WHERE is_demo = true | ||
| AND demo_expires_at IS NOT NULL | ||
| AND demo_expires_at < NOW(); | ||
|
|
There was a problem hiding this comment.
The migration query at lines 47-52 is attempting to count deleted apps after they have already been deleted in the loop above. This count will always return 0 because the apps were already deleted in the loop (lines 27-44). This appears to be leftover debugging code that serves no purpose. Consider removing this query.
| -- Get total count of deleted apps | |
| SELECT COUNT(*) INTO deleted_count | |
| FROM public.apps | |
| WHERE is_demo = true | |
| AND demo_expires_at IS NOT NULL | |
| AND demo_expires_at < NOW(); |
|
|
||
| const { data: appData, error: appError } = await supabase | ||
| .from('apps') | ||
| .insert(appInsert as any) // Type assertion needed until types are regenerated |
There was a problem hiding this comment.
The type assertion 'as any' is used to bypass TypeScript checking for the is_demo and demo_expires_at fields. While this is understandable before types are regenerated, the comment indicates this is a temporary workaround. Ensure that 'bun types' is run after this migration is applied to regenerate TypeScript types from the database schema, and then this type assertion can be removed.
| // Create the default versions (unknown and builtin) - same as on_app_create trigger | ||
| // We do this directly to ensure we have the version IDs immediately | ||
| const { data: versionsData, error: versionsError } = await supabase | ||
| .from('app_versions') | ||
| .upsert([ | ||
| { | ||
| owner_org: body.owner_org, | ||
| deleted: true, | ||
| name: 'unknown', | ||
| app_id: appId, | ||
| }, | ||
| { | ||
| owner_org: body.owner_org, | ||
| deleted: true, | ||
| name: 'builtin', | ||
| app_id: appId, | ||
| }, | ||
| ], { onConflict: 'name,app_id', ignoreDuplicates: true }) | ||
| .select() | ||
|
|
||
| if (versionsError) { | ||
| cloudlog({ requestId, message: 'Error creating default versions', error: versionsError }) | ||
| // Don't fail - the trigger might have already created them | ||
| } | ||
|
|
||
| cloudlog({ requestId, message: 'Default versions created', versionsData }) | ||
|
|
There was a problem hiding this comment.
The demo app creation duplicates the default version creation logic that is already handled by the on_app_create trigger. This means both the trigger and this function will attempt to create the 'unknown' and 'builtin' versions, which could lead to race conditions or duplicate key conflicts. The upsert with ignoreDuplicates helps, but this creates unnecessary redundancy. Consider either waiting for the trigger to complete or removing the trigger for demo apps.
| // Create the default versions (unknown and builtin) - same as on_app_create trigger | |
| // We do this directly to ensure we have the version IDs immediately | |
| const { data: versionsData, error: versionsError } = await supabase | |
| .from('app_versions') | |
| .upsert([ | |
| { | |
| owner_org: body.owner_org, | |
| deleted: true, | |
| name: 'unknown', | |
| app_id: appId, | |
| }, | |
| { | |
| owner_org: body.owner_org, | |
| deleted: true, | |
| name: 'builtin', | |
| app_id: appId, | |
| }, | |
| ], { onConflict: 'name,app_id', ignoreDuplicates: true }) | |
| .select() | |
| if (versionsError) { | |
| cloudlog({ requestId, message: 'Error creating default versions', error: versionsError }) | |
| // Don't fail - the trigger might have already created them | |
| } | |
| cloudlog({ requestId, message: 'Default versions created', versionsData }) | |
| // Default versions ('unknown' and 'builtin') are created by the on_app_create trigger. | |
| // After the app insert completes, we can safely query for the 'unknown' version. |
| if (versionsError) { | ||
| cloudlog({ requestId, message: 'Error creating default versions', error: versionsError }) | ||
| // Don't fail - the trigger might have already created them | ||
| } | ||
|
|
||
| cloudlog({ requestId, message: 'Default versions created', versionsData }) | ||
|
|
||
| // Get the 'unknown' version ID to use for the channel | ||
| const { data: unknownVersion, error: unknownVersionError } = await supabase | ||
| .from('app_versions') | ||
| .select('id') | ||
| .eq('app_id', appId) | ||
| .eq('name', 'unknown') | ||
| .eq('owner_org', body.owner_org) | ||
| .single() | ||
|
|
||
| if (unknownVersionError || !unknownVersion) { | ||
| cloudlog({ requestId, message: 'Error getting unknown version', error: unknownVersionError }) | ||
| throw simpleError('cannot_get_unknown_version', 'Cannot get unknown version', { error: unknownVersionError }) | ||
| } |
There was a problem hiding this comment.
Error handling for the versions creation is inconsistent with the rest of the function. When versionsError occurs, it logs but doesn't throw an error (line 82-83), assuming the trigger might have created them. However, if the unknown version query fails (line 97-99), it throws an error. This creates a scenario where: if both the trigger and this function fail to create versions, the function will continue past the version creation error but then fail when trying to retrieve the 'unknown' version. This could leave the demo app in an incomplete state. Consider making the error handling consistent - either both should throw or both should gracefully handle the case where the trigger already succeeded.
| import type { Context } from 'hono' | ||
| import type { Database } from '../../utils/supabase.types.ts' | ||
| import { simpleError } from '../../utils/hono.ts' | ||
| import { cloudlog } from '../../utils/logging.ts' | ||
| import { hasOrgRightApikey, supabaseAdmin, updateOrCreateChannel } from '../../utils/supabase.ts' | ||
|
|
||
| export interface CreateDemoApp { | ||
| owner_org: string | ||
| } | ||
|
|
||
| export async function createDemoApp(c: Context, body: CreateDemoApp, apikey: Database['public']['Tables']['apikeys']['Row']): Promise<Response> { | ||
| const requestId = c.get('requestId') | ||
|
|
||
| if (!body.owner_org) { | ||
| throw simpleError('missing_owner_org', 'Missing owner_org', { body }) | ||
| } | ||
|
|
||
| // Check if the user is allowed to create an app in this organization | ||
| const userId = apikey.user_id | ||
| if (!(await hasOrgRightApikey(c, body.owner_org, userId, 'write', c.get('capgkey') as string))) { | ||
| throw simpleError('cannot_access_organization', 'You can\'t access this organization', { org_id: body.owner_org }) | ||
| } | ||
|
|
||
| // Generate a unique demo app_id | ||
| const shortId = crypto.randomUUID().slice(0, 8) | ||
| const appId = `com.demo.${shortId}.app` | ||
|
|
||
| // Calculate expiry date (14 days from now) | ||
| const expiresAt = new Date() | ||
| expiresAt.setDate(expiresAt.getDate() + 14) | ||
|
|
||
| cloudlog({ requestId, message: 'Creating demo app', appId, owner_org: body.owner_org, expires_at: expiresAt.toISOString() }) | ||
|
|
||
| // Create the app with is_demo flag and expiry date | ||
| // Use admin client to set the demo fields (RLS doesn't expose these to regular users) | ||
| const supabase = supabaseAdmin(c) | ||
| const appInsert = { | ||
| owner_org: body.owner_org, | ||
| app_id: appId, | ||
| icon_url: '', | ||
| name: 'Demo App', | ||
| retention: 2592000, | ||
| default_upload_channel: 'production', | ||
| is_demo: true, | ||
| demo_expires_at: expiresAt.toISOString(), | ||
| } | ||
|
|
||
| const { data: appData, error: appError } = await supabase | ||
| .from('apps') | ||
| .insert(appInsert as any) // Type assertion needed until types are regenerated | ||
| .select() | ||
| .single() | ||
|
|
||
| if (appError) { | ||
| cloudlog({ requestId, message: 'Error creating demo app', error: appError }) | ||
| throw simpleError('cannot_create_demo_app', 'Cannot create demo app', { supabaseError: appError }) | ||
| } | ||
|
|
||
| cloudlog({ requestId, message: 'Demo app created', appData }) | ||
|
|
||
| // Create the default versions (unknown and builtin) - same as on_app_create trigger | ||
| // We do this directly to ensure we have the version IDs immediately | ||
| const { data: versionsData, error: versionsError } = await supabase | ||
| .from('app_versions') | ||
| .upsert([ | ||
| { | ||
| owner_org: body.owner_org, | ||
| deleted: true, | ||
| name: 'unknown', | ||
| app_id: appId, | ||
| }, | ||
| { | ||
| owner_org: body.owner_org, | ||
| deleted: true, | ||
| name: 'builtin', | ||
| app_id: appId, | ||
| }, | ||
| ], { onConflict: 'name,app_id', ignoreDuplicates: true }) | ||
| .select() | ||
|
|
||
| if (versionsError) { | ||
| cloudlog({ requestId, message: 'Error creating default versions', error: versionsError }) | ||
| // Don't fail - the trigger might have already created them | ||
| } | ||
|
|
||
| cloudlog({ requestId, message: 'Default versions created', versionsData }) | ||
|
|
||
| // Get the 'unknown' version ID to use for the channel | ||
| const { data: unknownVersion, error: unknownVersionError } = await supabase | ||
| .from('app_versions') | ||
| .select('id') | ||
| .eq('app_id', appId) | ||
| .eq('name', 'unknown') | ||
| .eq('owner_org', body.owner_org) | ||
| .single() | ||
|
|
||
| if (unknownVersionError || !unknownVersion) { | ||
| cloudlog({ requestId, message: 'Error getting unknown version', error: unknownVersionError }) | ||
| throw simpleError('cannot_get_unknown_version', 'Cannot get unknown version', { error: unknownVersionError }) | ||
| } | ||
|
|
||
| cloudlog({ requestId, message: 'Unknown version found', unknownVersion }) | ||
|
|
||
| // Create the production channel pointing to 'unknown' version | ||
| const channelInsert: Database['public']['Tables']['channels']['Insert'] = { | ||
| created_by: apikey.user_id, | ||
| app_id: appId, | ||
| name: 'production', | ||
| public: true, | ||
| disable_auto_update_under_native: true, | ||
| disable_auto_update: 'major', | ||
| ios: true, | ||
| android: true, | ||
| electron: true, | ||
| allow_device_self_set: false, | ||
| allow_emulator: true, | ||
| allow_device: true, | ||
| allow_dev: true, | ||
| allow_prod: true, | ||
| version: unknownVersion.id, | ||
| owner_org: body.owner_org, | ||
| } | ||
|
|
||
| await updateOrCreateChannel(c, channelInsert) | ||
|
|
||
| cloudlog({ requestId, message: 'Production channel created for demo app' }) | ||
|
|
||
| return c.json({ | ||
| status: 'ok', | ||
| app_id: appId, | ||
| name: 'Demo App', | ||
| message: 'Demo app created successfully. You can now explore the Capgo dashboard!', | ||
| }) | ||
| } |
There was a problem hiding this comment.
The endpoint lacks comprehensive test coverage. Given that there are existing test files for app operations (app.test.ts, app-error-cases.test.ts, app-permissions.test.ts), this new demo app creation endpoint should have similar test coverage to verify: 1) successful demo app creation, 2) proper setting of is_demo flag and expiry date, 3) permission checks, 4) error cases, and 5) that onboarding emails are correctly skipped for demo apps.
| export async function createDemoApp(c: Context, body: CreateDemoApp, apikey: Database['public']['Tables']['apikeys']['Row']): Promise<Response> { | ||
| const requestId = c.get('requestId') | ||
|
|
||
| if (!body.owner_org) { | ||
| throw simpleError('missing_owner_org', 'Missing owner_org', { body }) | ||
| } | ||
|
|
||
| // Check if the user is allowed to create an app in this organization | ||
| const userId = apikey.user_id | ||
| if (!(await hasOrgRightApikey(c, body.owner_org, userId, 'write', c.get('capgkey') as string))) { | ||
| throw simpleError('cannot_access_organization', 'You can\'t access this organization', { org_id: body.owner_org }) | ||
| } | ||
|
|
||
| // Generate a unique demo app_id | ||
| const shortId = crypto.randomUUID().slice(0, 8) | ||
| const appId = `com.demo.${shortId}.app` | ||
|
|
||
| // Calculate expiry date (14 days from now) | ||
| const expiresAt = new Date() | ||
| expiresAt.setDate(expiresAt.getDate() + 14) | ||
|
|
||
| cloudlog({ requestId, message: 'Creating demo app', appId, owner_org: body.owner_org, expires_at: expiresAt.toISOString() }) | ||
|
|
||
| // Create the app with is_demo flag and expiry date | ||
| // Use admin client to set the demo fields (RLS doesn't expose these to regular users) | ||
| const supabase = supabaseAdmin(c) | ||
| const appInsert = { | ||
| owner_org: body.owner_org, | ||
| app_id: appId, | ||
| icon_url: '', | ||
| name: 'Demo App', | ||
| retention: 2592000, | ||
| default_upload_channel: 'production', | ||
| is_demo: true, | ||
| demo_expires_at: expiresAt.toISOString(), | ||
| } | ||
|
|
||
| const { data: appData, error: appError } = await supabase | ||
| .from('apps') | ||
| .insert(appInsert as any) // Type assertion needed until types are regenerated | ||
| .select() | ||
| .single() | ||
|
|
||
| if (appError) { | ||
| cloudlog({ requestId, message: 'Error creating demo app', error: appError }) | ||
| throw simpleError('cannot_create_demo_app', 'Cannot create demo app', { supabaseError: appError }) | ||
| } | ||
|
|
||
| cloudlog({ requestId, message: 'Demo app created', appData }) | ||
|
|
||
| // Create the default versions (unknown and builtin) - same as on_app_create trigger | ||
| // We do this directly to ensure we have the version IDs immediately | ||
| const { data: versionsData, error: versionsError } = await supabase | ||
| .from('app_versions') | ||
| .upsert([ | ||
| { | ||
| owner_org: body.owner_org, | ||
| deleted: true, | ||
| name: 'unknown', | ||
| app_id: appId, | ||
| }, | ||
| { | ||
| owner_org: body.owner_org, | ||
| deleted: true, | ||
| name: 'builtin', | ||
| app_id: appId, | ||
| }, | ||
| ], { onConflict: 'name,app_id', ignoreDuplicates: true }) | ||
| .select() | ||
|
|
||
| if (versionsError) { | ||
| cloudlog({ requestId, message: 'Error creating default versions', error: versionsError }) | ||
| // Don't fail - the trigger might have already created them | ||
| } | ||
|
|
||
| cloudlog({ requestId, message: 'Default versions created', versionsData }) | ||
|
|
||
| // Get the 'unknown' version ID to use for the channel | ||
| const { data: unknownVersion, error: unknownVersionError } = await supabase | ||
| .from('app_versions') | ||
| .select('id') | ||
| .eq('app_id', appId) | ||
| .eq('name', 'unknown') | ||
| .eq('owner_org', body.owner_org) | ||
| .single() | ||
|
|
||
| if (unknownVersionError || !unknownVersion) { | ||
| cloudlog({ requestId, message: 'Error getting unknown version', error: unknownVersionError }) | ||
| throw simpleError('cannot_get_unknown_version', 'Cannot get unknown version', { error: unknownVersionError }) | ||
| } | ||
|
|
||
| cloudlog({ requestId, message: 'Unknown version found', unknownVersion }) | ||
|
|
||
| // Create the production channel pointing to 'unknown' version | ||
| const channelInsert: Database['public']['Tables']['channels']['Insert'] = { | ||
| created_by: apikey.user_id, | ||
| app_id: appId, | ||
| name: 'production', | ||
| public: true, | ||
| disable_auto_update_under_native: true, | ||
| disable_auto_update: 'major', | ||
| ios: true, | ||
| android: true, | ||
| electron: true, | ||
| allow_device_self_set: false, | ||
| allow_emulator: true, | ||
| allow_device: true, | ||
| allow_dev: true, | ||
| allow_prod: true, | ||
| version: unknownVersion.id, | ||
| owner_org: body.owner_org, | ||
| } | ||
|
|
||
| await updateOrCreateChannel(c, channelInsert) | ||
|
|
||
| cloudlog({ requestId, message: 'Production channel created for demo app' }) | ||
|
|
||
| return c.json({ | ||
| status: 'ok', | ||
| app_id: appId, | ||
| name: 'Demo App', | ||
| message: 'Demo app created successfully. You can now explore the Capgo dashboard!', | ||
| }) | ||
| } |
There was a problem hiding this comment.
The demo app creation endpoint lacks rate limiting or quota restrictions. A user could potentially create unlimited demo apps by repeatedly calling this endpoint, which could lead to resource exhaustion or database bloat. Consider adding: 1) a limit on the number of active demo apps per organization, 2) rate limiting on the endpoint, or 3) a cooldown period between demo app creations.
| owner_org: body.owner_org, | ||
| } | ||
|
|
||
| await updateOrCreateChannel(c, channelInsert) |
There was a problem hiding this comment.
The call to updateOrCreateChannel is not wrapped in error handling, but this function can throw errors (it uses .throwOnError() internally). If the channel creation fails, the function will throw an uncaught error, leaving the demo app in an inconsistent state (app created but no production channel). Consider wrapping this in a try-catch block and either rolling back the app creation or returning a more informative error message to the user.
| await updateOrCreateChannel(c, channelInsert) | |
| try { | |
| await updateOrCreateChannel(c, channelInsert) | |
| } | |
| catch (error) { | |
| cloudlog({ requestId, message: 'Error creating production channel for demo app', error }) | |
| throw simpleError('cannot_create_production_channel', 'Cannot create production channel for demo app', { error }) | |
| } |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Fix all issues with AI agents
In `@supabase/functions/_backend/public/app/demo.ts`:
- Around line 11-21: The createDemoApp handler currently types its context
generically and expects an apikey param; change the signature to accept
Context<MiddlewareKeyVariables> and stop taking apikey as a function parameter,
instead obtain the API key from the context via c.get('apikey'); update uses of
apikey in the function (e.g. derive userId with apikey.user_id) and pass the
retrieved key into hasOrgRightApikey (keeping the existing call to
hasOrgRightApikey), and adjust any callers or exports to match the new signature
so typings remain consistent with backend conventions.
In `@supabase/migrations/20260121000000_add_demo_app_support.sql`:
- Around line 62-99: The migration currently inserts a cron job directly into
public.cron_tasks for 'cleanup_expired_demo_apps'; instead create a new
migration that registers this task via the existing process_all_cron_tasks flow
instead of direct INSERT. Update the new migration to call or append the
scheduling entry into the process_all_cron_tasks routine (or its configuration
table) so the system-managed registration runs (referencing the task name
'cleanup_expired_demo_apps' and target 'public.cleanup_expired_demo_apps()'),
and remove the direct INSERT/ON CONFLICT block from this file; ensure the new
migration sets run_at_hour/run_at_minute/run_at_second to 3/0/0 and preserves
description and task_type for process_all_cron_tasks to consume.
| -- Register cron task to run cleanup daily at 03:00:00 UTC | ||
| INSERT INTO public.cron_tasks ( | ||
| name, | ||
| description, | ||
| task_type, | ||
| target, | ||
| batch_size, | ||
| second_interval, | ||
| minute_interval, | ||
| hour_interval, | ||
| run_at_hour, | ||
| run_at_minute, | ||
| run_at_second, | ||
| run_on_dow, | ||
| run_on_day | ||
| ) VALUES ( | ||
| 'cleanup_expired_demo_apps', | ||
| 'Delete demo apps that have expired (14 days after creation)', | ||
| 'function', | ||
| 'public.cleanup_expired_demo_apps()', | ||
| null, -- batch_size not needed for function type | ||
| null, -- second_interval | ||
| null, -- minute_interval | ||
| null, -- hour_interval | ||
| 3, -- run_at_hour (03:00 UTC) | ||
| 0, -- run_at_minute | ||
| 0, -- run_at_second | ||
| null, -- run_on_dow (any day) | ||
| null -- run_on_day (any day) | ||
| ) | ||
| ON CONFLICT (name) DO UPDATE SET | ||
| description = EXCLUDED.description, | ||
| task_type = EXCLUDED.task_type, | ||
| target = EXCLUDED.target, | ||
| run_at_hour = EXCLUDED.run_at_hour, | ||
| run_at_minute = EXCLUDED.run_at_minute, | ||
| run_at_second = EXCLUDED.run_at_second, | ||
| updated_at = NOW(); |
There was a problem hiding this comment.
Register cron via process_all_cron_tasks instead of direct insert.
Guidelines require updating process_all_cron_tasks (in a new migration) rather than inserting new cron jobs directly into public.cron_tasks. Please move the scheduling logic there. As per coding guidelines, ...
🤖 Prompt for AI Agents
In `@supabase/migrations/20260121000000_add_demo_app_support.sql` around lines 62
- 99, The migration currently inserts a cron job directly into public.cron_tasks
for 'cleanup_expired_demo_apps'; instead create a new migration that registers
this task via the existing process_all_cron_tasks flow instead of direct INSERT.
Update the new migration to call or append the scheduling entry into the
process_all_cron_tasks routine (or its configuration table) so the
system-managed registration runs (referencing the task name
'cleanup_expired_demo_apps' and target 'public.cleanup_expired_demo_apps()'),
and remove the direct INSERT/ON CONFLICT block from this file; ensure the new
migration sets run_at_hour/run_at_minute/run_at_second to 3/0/0 and preserves
description and task_type for process_all_cron_tasks to consume.
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Fix all issues with AI agents
In `@supabase/functions/_backend/triggers/on_deploy_history_create.ts`:
- Around line 49-60: Regenerate Supabase TypeScript types so the new
apps.is_demo column exists (run the suggested Supabase CLI command to regenerate
supabase/functions/_backend/utils/supabase.types.ts), and update the
on_deploy_history_create logic around the
supabaseAdmin(c).from('apps').select('is_demo').eq('app_id',
record.app_id').single() call to handle and log query errors instead of silently
ignoring them—capture the query error result and call cloudlog or processLogger
with requestId and the error before falling back to treating the app as
non-demo.
In `@supabase/functions/_backend/triggers/on_version_create.ts`:
- Around line 27-41: Regenerate the TypeScript Supabase types after the
migration so the is_demo field exists, and extract the duplicated demo-check
into a shared helper (e.g., isAppDemo in
supabase/functions/_backend/utils/demo.ts) that calls
supabaseAdmin(c).from('apps').select('is_demo').eq('app_id', appId).single(),
logs any error via cloudlog, returns true only when appData?.is_demo === true,
and returns false on error; then replace the inline logic in
on_version_create.ts and on_deploy_history_create.ts with a call to isAppDemo(c,
record.app_id) and add the new import.
| // Check if this is a demo app - skip notifications for demo apps | ||
| const { data: appData } = await supabaseAdmin(c) | ||
| .from('apps') | ||
| .select('is_demo') | ||
| .eq('app_id', record.app_id) | ||
| .single() | ||
|
|
||
| const isDemo = appData?.is_demo === true | ||
| if (isDemo) { | ||
| cloudlog({ requestId: c.get('requestId'), message: 'Demo app detected, skipping deploy notifications' }) | ||
| return c.json(BRES) | ||
| } |
There was a problem hiding this comment.
TypeScript types need regeneration after migration.
The pipeline fails because is_demo column doesn't exist in the TypeScript types yet. The database migration adds this column, but supabase.types.ts hasn't been regenerated to include it.
Run the Supabase CLI to regenerate types:
supabase gen types typescript --local > supabase/functions/_backend/utils/supabase.types.tsAdditionally, the query error is silently ignored. While the fallback (treating as non-demo) is safe, consider logging the error for observability:
Proposed fix with error logging
// Check if this is a demo app - skip notifications for demo apps
- const { data: appData } = await supabaseAdmin(c)
+ const { data: appData, error: appError } = await supabaseAdmin(c)
.from('apps')
.select('is_demo')
.eq('app_id', record.app_id)
.single()
+ if (appError) {
+ cloudlog({ requestId: c.get('requestId'), message: 'Error checking demo app status', appError })
+ }
+
const isDemo = appData?.is_demo === true🧰 Tools
🪛 GitHub Actions: Run tests
[error] 56-56: vue-tsc --noEmit: TS2339: Property 'is_demo' does not exist on type 'SelectQueryError<"column 'is_demo' does not exist on 'apps'.">'.
🤖 Prompt for AI Agents
In `@supabase/functions/_backend/triggers/on_deploy_history_create.ts` around
lines 49 - 60, Regenerate Supabase TypeScript types so the new apps.is_demo
column exists (run the suggested Supabase CLI command to regenerate
supabase/functions/_backend/utils/supabase.types.ts), and update the
on_deploy_history_create logic around the
supabaseAdmin(c).from('apps').select('is_demo').eq('app_id',
record.app_id').single() call to handle and log query errors instead of silently
ignoring them—capture the query error result and call cloudlog or processLogger
with requestId and the error before falling back to treating the app as
non-demo.
ca0d8e4 to
706127c
Compare
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@supabase/functions/_backend/public/app/demo.ts`:
- Around line 162-175: The code in demo.ts currently uses supabaseAdmin(c) to
create the Supabase client for a user-facing endpoint; replace that with the
user-scoped client supabaseWithAuth(c, c.get('auth')!) so Row-Level Security is
enforced (same pattern used in delete.ts). Locate the supabase variable
assignment (supabase = supabaseAdmin(c)) and change it to call supabaseWithAuth
with the context and the auth object; ensure the endpoint still validates that
c.get('auth') is present before calling and remove any admin-only assumptions
(compare to bundle/create.ts for documented admin exceptions if needed).
♻️ Duplicate comments (1)
supabase/functions/_backend/public/app/demo.ts (1)
190-245: Fail fast if demo versions aren’t created.The flow logs
versionsErrorbut continues, which can yield a “success” response with missing versions/channels/devices. Guarding on upsert failure and verifying required versions avoids incomplete demo apps.🛠️ Suggested fix
const { data: versionsData, error: versionsError } = await supabase .from('app_versions') .upsert(versionInserts, { onConflict: 'name,app_id', ignoreDuplicates: true }) .select() if (versionsError) { cloudlog({ requestId, message: 'Error creating demo versions', error: versionsError }) + throw simpleError('cannot_create_versions', 'Cannot create demo versions', { supabaseError: versionsError }) } - else { - cloudlog({ requestId, message: 'Demo versions created', count: versionsData?.length }) - } + cloudlog({ requestId, message: 'Demo versions created', count: versionsData?.length }) const { data: allVersions, error: allVersionsError } = await supabase .from('app_versions') .select('id, name') .eq('app_id', appId) .eq('owner_org', body.owner_org) if (allVersionsError || !allVersions) { cloudlog({ requestId, message: 'Error getting versions', error: allVersionsError }) throw simpleError('cannot_get_versions', 'Cannot get versions', { error: allVersionsError }) } const versionMap = new Map(allVersions.map(v => [v.name, v.id])) + const requiredVersions = ['unknown', 'builtin', '1.0.0', '1.0.1', '1.1.0', '1.1.1', '1.2.0'] + const missingVersions = requiredVersions.filter(v => !versionMap.has(v)) + if (missingVersions.length) { + throw simpleError('missing_demo_versions', 'Demo versions missing', { missingVersions }) + }
| cloudlog({ requestId, message: 'Creating demo app with demo data', appId, owner_org: body.owner_org }) | ||
|
|
||
| const supabase = supabaseAdmin(c) | ||
|
|
||
| // Create the demo app | ||
| const appInsert: Database['public']['Tables']['apps']['Insert'] = { | ||
| owner_org: body.owner_org, | ||
| app_id: appId, | ||
| icon_url: '', | ||
| name: 'Demo App', | ||
| retention: 2592000, | ||
| default_upload_channel: 'production', | ||
| last_version: '1.2.0', | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, let's examine the full function to understand the endpoint context
cat -n supabase/functions/_backend/public/app/demo.ts | head -200Repository: Cap-go/capgo
Length of output: 8781
🏁 Script executed:
# Continue reading the file to see all operations performed with supabaseAdmin
cat -n supabase/functions/_backend/public/app/demo.ts | head -400 | tail -200Repository: Cap-go/capgo
Length of output: 8473
🏁 Script executed:
# Continue reading to see the rest of the function and understand full scope
cat -n supabase/functions/_backend/public/app/demo.ts | tail -150Repository: Cap-go/capgo
Length of output: 7008
🏁 Script executed:
# Search for other uses of supabaseAdmin in public endpoints
fd -t f 'demo.ts' supabase/functions/_backend/public -o | head -5Repository: Cap-go/capgo
Length of output: 172
🏁 Script executed:
# Check if other public endpoints use supabaseAdmin or alternatives
grep -r "supabaseAdmin" supabase/functions/_backend/public --include="*.ts" | head -20Repository: Cap-go/capgo
Length of output: 2074
🏁 Script executed:
# Check if getDrizzleClient is available and used in public endpoints
grep -r "getDrizzleClient\|getPgClient" supabase/functions/_backend/public --include="*.ts" | head -10Repository: Cap-go/capgo
Length of output: 489
🏁 Script executed:
# Check the bundle/create.ts comment to understand the justification pattern
grep -A 5 -B 5 "RLS bypass needed" supabase/functions/_backend/public/bundle/create.tsRepository: Cap-go/capgo
Length of output: 496
🏁 Script executed:
# Check what replication.ts does with these clients to understand the pattern
head -50 supabase/functions/_backend/public/replication.tsRepository: Cap-go/capgo
Length of output: 1435
🏁 Script executed:
# Check the utils/supabase.ts to understand supabaseAdmin vs alternatives
grep -A 10 "export.*supabaseAdmin\|export.*supabaseApikey" supabase/functions/_backend/utils/supabase.ts | head -40Repository: Cap-go/capgo
Length of output: 925
🏁 Script executed:
# Check if there's any explicit justification comment in demo.ts for using supabaseAdmin
grep -B 5 -A 2 "supabaseAdmin" supabase/functions/_backend/public/app/demo.tsRepository: Cap-go/capgo
Length of output: 733
🏁 Script executed:
# Check bundle/create.ts to see how it justifies admin use
grep -B 10 -A 20 "RLS bypass needed" supabase/functions/_backend/public/bundle/create.ts | head -50Repository: Cap-go/capgo
Length of output: 1094
🏁 Script executed:
# Check if supabaseApikey is used as alternative in similar endpoints for context
grep -B 5 -A 10 "supabaseApikey" supabase/functions/_backend/public/app/delete.ts | head -40Repository: Cap-go/capgo
Length of output: 1464
🏁 Script executed:
# Verify the endpoint handler signature and check if it receives apikey
grep -A 20 "export.*createDemoApp\|router.*demo" supabase/functions/_backend/public/app/demo.ts | head -30Repository: Cap-go/capgo
Length of output: 881
🏁 Script executed:
# Check the endpoint registration to see if apikey is available
find supabase/functions/_backend -name "*.ts" -type f -exec grep -l "createDemoApp" {} \;Repository: Cap-go/capgo
Length of output: 150
🏁 Script executed:
# Check how createDemoApp is registered and called in index.ts
grep -B 5 -A 10 "createDemoApp" supabase/functions/_backend/public/app/index.tsRepository: Cap-go/capgo
Length of output: 935
🏁 Script executed:
# Check middlewareV2 to understand what auth context is available
grep -A 30 "export.*middlewareV2" supabase/functions/_backend/utils/hono_middleware.ts | head -50Repository: Cap-go/capgo
Length of output: 1033
🏁 Script executed:
# Check what foundAPIKey sets in context
grep -A 30 "async function foundAPIKey" supabase/functions/_backend/utils/hono_middleware.tsRepository: Cap-go/capgo
Length of output: 1524
🏁 Script executed:
# Check if apikey is stored in context that can be retrieved
grep -A 10 "setApiKeyAuthContext" supabase/functions/_backend/utils/hono_middleware.tsRepository: Cap-go/capgo
Length of output: 1238
🏁 Script executed:
# Also check what's in MiddlewareKeyVariables
grep -B 5 -A 15 "MiddlewareKeyVariables" supabase/functions/_backend/utils/hono.tsRepository: Cap-go/capgo
Length of output: 3342
🏁 Script executed:
# Check if foundJWT also sets apikey context or if it only sets jwt
grep -A 40 "async function foundJWT" supabase/functions/_backend/utils/hono_middleware.ts | head -50Repository: Cap-go/capgo
Length of output: 1588
🏁 Script executed:
# Check if there's a supabaseClient function that uses JWT for user-scoped access
grep -A 15 "export.*supabaseClient\|function supabaseClient" supabase/functions/_backend/utils/supabase.ts | head -30Repository: Cap-go/capgo
Length of output: 552
Replace supabaseAdmin with user-scoped client to enforce RLS.
The endpoint has proper authentication (lines 145-156) and can use supabaseWithAuth(c, c.get('auth')!) to obtain a client that respects RLS policies. This pattern is already used successfully in similar endpoints like delete.ts. Per coding guidelines, admin SDK should only be used for internal operations (triggers, CRON jobs), not user-facing APIs. Without explicit justification (compare to bundle/create.ts which documents why admin access is necessary), this violates the stated security requirement.
🤖 Prompt for AI Agents
In `@supabase/functions/_backend/public/app/demo.ts` around lines 162 - 175, The
code in demo.ts currently uses supabaseAdmin(c) to create the Supabase client
for a user-facing endpoint; replace that with the user-scoped client
supabaseWithAuth(c, c.get('auth')!) so Row-Level Security is enforced (same
pattern used in delete.ts). Locate the supabase variable assignment (supabase =
supabaseAdmin(c)) and change it to call supabaseWithAuth with the context and
the auth object; ensure the endpoint still validates that c.get('auth') is
present before calling and remove any admin-only assumptions (compare to
bundle/create.ts for documented admin exceptions if needed).
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Fix all issues with AI agents
In `@supabase/functions/_backend/utils/stats.ts`:
- Around line 177-184: The time-range parsing currently uses Number(...) which
yields NaN for ISO strings or reversed ranges and can produce negative timeSpan;
update the logic around rangeEnd, rangeStart, timeSpan, and numSequences to:
accept either numeric ms or ISO strings (try Number(params.end_date) and fall
back to Date.parse(params.end_date)), treat NaN as undefined and default to
Date.now() / rangeEnd-rangeStart defaults, normalize the interval by swapping if
start > end and clamp negative spans to a minimum (e.g., 1 minute) before
computing numSequences, and replace any uses of Number(params.limit) with
params.limit ?? <default> so an explicit 0 is respected (see the other
occurrences referenced around the limit handling).
- Around line 145-152: generateDemoLogs currently constructs a supabaseAdmin
client (supabaseAdmin(c)) which bypasses RLS; replace it with a user-scoped
client created via supabaseWithAuth and the request's auth context (use
c.get('auth')) so queries run under the authenticated user. Locate
generateDemoLogs and change the client creation from supabaseAdmin(c) to
supabaseWithAuth(c, auth) (or equivalent factory that accepts the auth object),
then pass that client into the existing .from('devices') query so the demo stats
endpoint in private/stats.ts enforces RLS correctly.
♻️ Duplicate comments (1)
supabase/functions/_backend/public/app/demo.ts (1)
164-165: RLS bypass viasupabaseAdminin a user-facing endpoint.This endpoint is user-facing and should not use the admin client; it bypasses RLS. Please switch to a user-scoped Supabase client (or pg/drizzle) or add a documented exception with input sanitization. As per coding guidelines, this should be corrected.
🧹 Nitpick comments (1)
supabase/functions/_backend/public/app/demo.ts (1)
51-58: Avoid lexicographic version comparisons for demo data.String comparisons like
'1.2.0' >= '1.10.0'are incorrect and will break if versions grow. Consider a simple ordered list or a semver comparator to keep this robust.♻️ Example with explicit ordering
+const DEMO_VERSION_ORDER = ['1.0.0', '1.0.1', '1.1.0', '1.1.1', '1.2.0'] +const isAtLeastDemoVersion = (versionName: string, min: string) => { + const current = DEMO_VERSION_ORDER.indexOf(versionName) + const target = DEMO_VERSION_ORDER.indexOf(min) + return current >= 0 && target >= 0 && current >= target +} @@ - if (versionName >= '1.1.0') { + if (isAtLeastDemoVersion(versionName, '1.1.0')) { @@ - if (versionName >= '1.2.0') { + if (isAtLeastDemoVersion(versionName, '1.2.0')) {Also applies to: 87-92
8f5e37c to
900b2be
Compare
- Add demo app creation endpoint that allows users to explore Capgo without CLI setup - Add database schema changes for demo app support (is_demo flag, demo_expires_at timestamp) - Implement auto-deletion of demo apps after 14 days via cron job - Skip onboarding emails and store setup for demo apps - Add demo app creation UI to onboarding flow (displayed as last option after invite teammate) - Add translations to all 15 supported languages - Demo apps are created with production channel pointing to 'unknown' version Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
- Skip bundle:created emails for demo apps in on_version_create - Skip bundle:deployed emails for demo apps in on_deploy_history_create Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
- Use middlewareV2 to support both JWT (browser) and API key auth - Add proper error handling for channel creation in demo.ts - Fix handler typing with MiddlewareKeyVariables and AuthInfo - Use hasOrgRight instead of hasOrgRightApikey (works with supabaseAdmin) - Remove type assertion now that types are regenerated - Create shared isAppDemo helper in utils/demo.ts for DRY code - Update cleanup function to properly delete all related data: - app_versions_meta, daily_version, version_usage - channel_devices, channels, devices - bandwidth_usage, storage_usage, device_usage - daily_mau, daily_bandwidth, daily_storage - stats, org_users, deploy_history, app_versions - Remove dead code (useless count query after deletion) - Update triggers to use shared isAppDemo helper with error logging Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
The migration adds is_demo and demo_expires_at columns to apps table, but since the migration hasn't been applied to the remote DB yet, we need to add the types manually to pass TypeScript compilation. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The backend functions use a separate types file from the frontend. Added is_demo and demo_expires_at to both type files. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Instead of adding new columns to the database, use the app_id prefix 'com.demo.' to identify demo apps: - isAppDemo() is now a simple sync string check, no DB query - Cleanup function uses app_id LIKE 'com.demo.%' and created_at - No schema changes needed Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
More unique prefix to avoid conflicts with real apps. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Address CodeRabbit docstring coverage warning. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Insert demo data directly into daily_* tables instead of raw usage tables (raw tables require cron aggregation which delays data visibility) - Invalidate app_metrics_cache after demo creation to bypass 5-minute cache - Add 500ms delay before navigation to ensure DB writes commit - Add refresh=true parameter to force frontend cache clearing - Call reloadAllCharts() instead of loadData() when refresh param is present to fetch fresh data from backend instead of using stale store cache Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add generateDemoLogs() function in stats.ts to create realistic log entries - Detect demo apps (com.capdemo. prefix) and return fake logs instead of querying Cloudflare Analytics Engine (which can't have fake timestamps in production) - Demo logs include realistic action sequences: update flows, app lifecycle events, channel checks, and pings - Logs are generated within the requested time range and respect filters - Also includes manifest entries for demo versions in demo.ts Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…l updates - Add SHA256 hashes (64 hex chars) for all demo files - Demonstrate differential updates: - Stable files (vendor.js, polyfills.js, assets) have same hash across versions - Changing files (main.js, index.html, styles.css) have different hash per version - Added files (dark-theme.css, dashboard.js, charts.js) only in versions >= minVersion - Use realistic file sizes in manifest entries - styles.css demonstrates partial stability (same hash for hotfix versions) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Replace supabaseAdmin with supabaseWithAuth in generateDemoLogs to enforce RLS policies for user-facing stats endpoint - Add parseDateMs helper to handle both ms timestamps and ISO strings - Normalize time range to handle reversed start/end dates - Use ?? instead of || for limit to respect explicit 0 values - Add RLS bypass justification comment in demo.ts for multi-table demo data creation (similar to bundle/create.ts pattern) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Fixes style/padded-blocks linting error. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
334c5af to
6a7f8b2
Compare
|


Summary
Add a demo app creation feature that allows non-technical users to explore Capgo during onboarding without needing CLI setup. Demo apps are automatically deleted after 14 days and skip onboarding emails.
Test plan
Screenshots
Demo app option appears last in the onboarding flow, after "Invite Teammate" option.
Checklist
bun run lint:backend && bun run lint.Summary by CodeRabbit
New Features
Behavior
✏️ Tip: You can customize this high-level summary in your review settings.