From 377279c0a5bdafb848bae11487ddf558d7cb1b53 Mon Sep 17 00:00:00 2001 From: Addison Emig Date: Wed, 19 Nov 2025 14:16:10 -0600 Subject: [PATCH 01/33] docs: update spec --- specs/000-mvp.md | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/specs/000-mvp.md b/specs/000-mvp.md index 3c824a1..3a2649b 100644 --- a/specs/000-mvp.md +++ b/specs/000-mvp.md @@ -97,7 +97,7 @@ This document contains the basic roadmap for the minimal viable product (MVP). ### Authentication System -- [ ] **Setup Supabase email auth** +- [x] **Setup Supabase email auth** - [x] Configure Supabase project with email OTP - [x] Create initial user from Supabase dashboard - [x] Implement route protection middleware @@ -109,21 +109,22 @@ This document contains the basic roadmap for the minimal viable product (MVP). - [x] Update `INFRASTRUCTURE.md` with Supabase auth information - [x] Redirect from `/` to `/dashboard` for signed-in users - [x] Associate authenticated users with our user table in DB - - [ ] Test sign-on in preview environment - [ ] **Create invite validation system** - - [ ] Implement invite code generation - - [ ] Add invite usage tracking + - [ ] Add dedicated page for invite code + - [ ] List status of user's invite codes + - [ ] Allow user to create new invite codes - [ ] **Build signup flow** - - [ ] Create invite code validation page - - [ ] Implement email OTP verification - - [ ] Create user record with invited_by relationship - - [ ] Add initial contact info (default to hidden) + - [ ] Create signup page + - [ ] Once user inputs valid invite code, use Supabase edge function to set things up: + - [ ] Update user record with invited_by relationship + - [ ] Add new accepted connection between two users - [ ] Build basic onboarding flow - [ ] Contact visibility settings - - [ ] About information - - [ ] Avatar upload + - [ ] Display Name + - [ ] Bio information + - [ ] For now - use https://www.dicebear.com/styles/initials/ to automatically generate and upload avatar for new user - [ ] **Setup Supabase SMS auth** - [ ] Configure Supabase project with SMS OTP From 143eaa4dcd55aaa22bef45d3ef1257e2bdf9018b Mon Sep 17 00:00:00 2001 From: Addison Emig Date: Wed, 19 Nov 2025 14:25:19 -0600 Subject: [PATCH 02/33] feat: update email title --- supabase/config.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supabase/config.toml b/supabase/config.toml index 78e3be2..b3b0918 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -202,7 +202,7 @@ otp_expiry = 3600 # content_path = "./supabase/templates/invite.html" [auth.email.template.magic_link] -subject = "Your sign-in code" +subject = "One-Time Password" content_path = "./supabase/templates/magic_link.html" # Uncomment to customize notification email template From f1dde8a440b72eb33d2cc33f80e285bb1c263879 Mon Sep 17 00:00:00 2001 From: Addison Emig Date: Thu, 20 Nov 2025 07:00:13 -0600 Subject: [PATCH 03/33] feat: basic UI --- specs/000-mvp.md | 8 +- src/components/react/InviteManager.tsx | 238 +++++++++ src/lib/database.types.ts | 657 +++++++++++++++++++++++++ src/pages/api/invites/create.ts | 90 ++++ src/pages/api/invites/revoke.ts | 53 ++ src/pages/dashboard.astro | 36 ++ src/pages/invites.astro | 46 ++ 7 files changed, 1124 insertions(+), 4 deletions(-) create mode 100644 src/components/react/InviteManager.tsx create mode 100644 src/lib/database.types.ts create mode 100644 src/pages/api/invites/create.ts create mode 100644 src/pages/api/invites/revoke.ts create mode 100644 src/pages/invites.astro diff --git a/specs/000-mvp.md b/specs/000-mvp.md index 3a2649b..bad68f0 100644 --- a/specs/000-mvp.md +++ b/specs/000-mvp.md @@ -110,10 +110,10 @@ This document contains the basic roadmap for the minimal viable product (MVP). - [x] Redirect from `/` to `/dashboard` for signed-in users - [x] Associate authenticated users with our user table in DB -- [ ] **Create invite validation system** - - [ ] Add dedicated page for invite code - - [ ] List status of user's invite codes - - [ ] Allow user to create new invite codes +- [x] **Add invite codes page** + - [x] Add dedicated page for invite code + - [x] List status of user's invite codes + - [x] Allow user to create new invite codes - [ ] **Build signup flow** - [ ] Create signup page diff --git a/src/components/react/InviteManager.tsx b/src/components/react/InviteManager.tsx new file mode 100644 index 0000000..abc1141 --- /dev/null +++ b/src/components/react/InviteManager.tsx @@ -0,0 +1,238 @@ +import React, { useState } from 'react'; +import type { Tables } from '../../lib/database.types'; + +type Invite = Tables<'invite'>; + +interface InviteManagerProps { + initialInvites: Invite[]; +} + +export default function InviteManager({ initialInvites }: InviteManagerProps) { + const [invites, setInvites] = useState(initialInvites); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const activeInvites = invites.filter( + (invite) => !invite.used_at && !invite.revoked_at + ); + + const pastInvites = invites.filter( + (invite) => invite.used_at || invite.revoked_at + ); + + // Sort past invites by creation date desc + pastInvites.sort( + (a, b) => + new Date(b.created_at).getTime() - new Date(a.created_at).getTime() + ); + + const createInvite = async () => { + setLoading(true); + setError(null); + try { + const res = await fetch('/api/invites/create', { + method: 'POST', + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || 'Failed to create invite'); + } + + const newInvite = await res.json(); + setInvites([newInvite, ...invites]); + } catch (err) { + if (err instanceof Error) { + setError(err.message); + } else { + setError('An unexpected error occurred'); + } + } finally { + setLoading(false); + } + }; + + const revokeInvite = async (inviteId: string) => { + if (!confirm('Are you sure you want to revoke this invite code?')) return; + + try { + const res = await fetch('/api/invites/revoke', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ inviteId }), + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || 'Failed to revoke invite'); + } + + const updatedInvite = await res.json(); + setInvites( + invites.map((inv) => + inv.id === updatedInvite.id ? updatedInvite : inv + ) + ); + } catch (err) { + if (err instanceof Error) { + alert(err.message); + } else { + alert('An unexpected error occurred'); + } + } + }; + + const copyCode = (code: string) => { + navigator.clipboard.writeText(code); + alert('Invite code copied to clipboard!'); + }; + + const shareCode = async (code: string) => { + if (navigator.share) { + try { + await navigator.share({ + title: 'Join Market', + text: `Join me on Market! Use my invite code: ${code}`, + url: window.location.origin + '/auth/login', // Or a signup specific page if it existed + }); + } catch (err) { + console.error('Error sharing:', err); + } + } else { + copyCode(code); + } + }; + + return ( +
+
+
+
+

+ Active Invites +

+ +
+ + {error && ( +
+ {error} +
+ )} + + {activeInvites.length === 0 ? ( +

No active invite codes.

+ ) : ( +
+ {activeInvites.map((invite) => ( +
+
+
+ {invite.invite_code} +
+
+ Created:{' '} + {new Date(invite.created_at).toLocaleDateString()} +
+
+ +
+ + + +
+
+ ))} +
+ )} +
+ +
+

+ History +

+ {pastInvites.length === 0 ? ( +

No past invites.

+ ) : ( +
+ + + + + + + + + + {pastInvites.map((invite) => { + let status = 'Unknown'; + let date = invite.created_at; + + if (invite.revoked_at) { + status = 'Revoked'; + date = invite.revoked_at; + } else if (invite.used_at) { + status = 'Used'; + date = invite.used_at; + } + + return ( + + + + + + ); + })} + +
CodeStatusDate
+ {invite.invite_code} + + + {status} + + + {new Date(date).toLocaleDateString()} +
+
+ )} +
+
+
+ ); +} diff --git a/src/lib/database.types.ts b/src/lib/database.types.ts new file mode 100644 index 0000000..79ba6e7 --- /dev/null +++ b/src/lib/database.types.ts @@ -0,0 +1,657 @@ +export type Json = + | string + | number + | boolean + | null + | { [key: string]: Json | undefined } + | Json[]; + +export type Database = { + graphql_public: { + Tables: { + [_ in never]: never; + }; + Views: { + [_ in never]: never; + }; + Functions: { + graphql: { + Args: { + extensions?: Json; + operationName?: string; + query?: string; + variables?: Json; + }; + Returns: Json; + }; + }; + Enums: { + [_ in never]: never; + }; + CompositeTypes: { + [_ in never]: never; + }; + }; + public: { + Tables: { + category: { + Row: { + created_at: string; + description: string | null; + id: string; + name: string; + }; + Insert: { + created_at?: string; + description?: string | null; + id: string; + name: string; + }; + Update: { + created_at?: string; + description?: string | null; + id?: string; + name?: string; + }; + Relationships: []; + }; + connection: { + Row: { + created_at: string; + id: string; + status: Database['public']['Enums']['connection_status']; + user_a: string; + user_b: string; + }; + Insert: { + created_at?: string; + id?: string; + status?: Database['public']['Enums']['connection_status']; + user_a: string; + user_b: string; + }; + Update: { + created_at?: string; + id?: string; + status?: Database['public']['Enums']['connection_status']; + user_a?: string; + user_b?: string; + }; + Relationships: [ + { + foreignKeyName: 'connection_user_a_fkey'; + columns: ['user_a']; + isOneToOne: false; + referencedRelation: 'user'; + referencedColumns: ['id']; + }, + { + foreignKeyName: 'connection_user_b_fkey'; + columns: ['user_b']; + isOneToOne: false; + referencedRelation: 'user'; + referencedColumns: ['id']; + }, + ]; + }; + contact_info: { + Row: { + contact_type: Database['public']['Enums']['contact_type']; + created_at: string; + id: string; + user_id: string; + value: string; + visibility: Database['public']['Enums']['visibility']; + }; + Insert: { + contact_type: Database['public']['Enums']['contact_type']; + created_at?: string; + id?: string; + user_id: string; + value: string; + visibility?: Database['public']['Enums']['visibility']; + }; + Update: { + contact_type?: Database['public']['Enums']['contact_type']; + created_at?: string; + id?: string; + user_id?: string; + value?: string; + visibility?: Database['public']['Enums']['visibility']; + }; + Relationships: [ + { + foreignKeyName: 'contact_info_user_id_fkey'; + columns: ['user_id']; + isOneToOne: false; + referencedRelation: 'user'; + referencedColumns: ['id']; + }, + ]; + }; + invite: { + Row: { + created_at: string; + id: string; + invite_code: string; + inviter_id: string; + revoked_at: string | null; + used_at: string | null; + used_by: string | null; + }; + Insert: { + created_at?: string; + id?: string; + invite_code: string; + inviter_id: string; + revoked_at?: string | null; + used_at?: string | null; + used_by?: string | null; + }; + Update: { + created_at?: string; + id?: string; + invite_code?: string; + inviter_id?: string; + revoked_at?: string | null; + used_at?: string | null; + used_by?: string | null; + }; + Relationships: [ + { + foreignKeyName: 'invite_inviter_id_fkey'; + columns: ['inviter_id']; + isOneToOne: false; + referencedRelation: 'user'; + referencedColumns: ['id']; + }, + { + foreignKeyName: 'invite_used_by_fkey'; + columns: ['used_by']; + isOneToOne: false; + referencedRelation: 'user'; + referencedColumns: ['id']; + }, + ]; + }; + item: { + Row: { + category_id: string; + created_at: string; + description: string | null; + id: string; + price_string: string | null; + status: Database['public']['Enums']['item_status']; + title: string; + type: Database['public']['Enums']['item_type']; + updated_at: string; + user_id: string; + visibility: Database['public']['Enums']['visibility']; + }; + Insert: { + category_id: string; + created_at?: string; + description?: string | null; + id?: string; + price_string?: string | null; + status?: Database['public']['Enums']['item_status']; + title: string; + type: Database['public']['Enums']['item_type']; + updated_at?: string; + user_id: string; + visibility?: Database['public']['Enums']['visibility']; + }; + Update: { + category_id?: string; + created_at?: string; + description?: string | null; + id?: string; + price_string?: string | null; + status?: Database['public']['Enums']['item_status']; + title?: string; + type?: Database['public']['Enums']['item_type']; + updated_at?: string; + user_id?: string; + visibility?: Database['public']['Enums']['visibility']; + }; + Relationships: [ + { + foreignKeyName: 'item_category_id_fkey'; + columns: ['category_id']; + isOneToOne: false; + referencedRelation: 'category'; + referencedColumns: ['id']; + }, + { + foreignKeyName: 'item_user_id_fkey'; + columns: ['user_id']; + isOneToOne: false; + referencedRelation: 'user'; + referencedColumns: ['id']; + }, + ]; + }; + item_image: { + Row: { + alt_text: string | null; + created_at: string; + id: string; + item_id: string; + order_index: number; + url: string; + }; + Insert: { + alt_text?: string | null; + created_at?: string; + id?: string; + item_id: string; + order_index?: number; + url: string; + }; + Update: { + alt_text?: string | null; + created_at?: string; + id?: string; + item_id?: string; + order_index?: number; + url?: string; + }; + Relationships: [ + { + foreignKeyName: 'item_image_item_id_fkey'; + columns: ['item_id']; + isOneToOne: false; + referencedRelation: 'item'; + referencedColumns: ['id']; + }, + ]; + }; + message: { + Row: { + content: string; + created_at: string; + id: string; + read: boolean; + sender_id: string; + thread_id: string; + }; + Insert: { + content: string; + created_at?: string; + id?: string; + read?: boolean; + sender_id: string; + thread_id: string; + }; + Update: { + content?: string; + created_at?: string; + id?: string; + read?: boolean; + sender_id?: string; + thread_id?: string; + }; + Relationships: [ + { + foreignKeyName: 'message_sender_id_fkey'; + columns: ['sender_id']; + isOneToOne: false; + referencedRelation: 'user'; + referencedColumns: ['id']; + }, + { + foreignKeyName: 'message_thread_id_fkey'; + columns: ['thread_id']; + isOneToOne: false; + referencedRelation: 'thread'; + referencedColumns: ['id']; + }, + ]; + }; + message_image: { + Row: { + created_at: string; + id: string; + message_id: string; + order_index: number; + url: string; + }; + Insert: { + created_at?: string; + id?: string; + message_id: string; + order_index?: number; + url: string; + }; + Update: { + created_at?: string; + id?: string; + message_id?: string; + order_index?: number; + url?: string; + }; + Relationships: [ + { + foreignKeyName: 'message_image_message_id_fkey'; + columns: ['message_id']; + isOneToOne: false; + referencedRelation: 'message'; + referencedColumns: ['id']; + }, + ]; + }; + thread: { + Row: { + created_at: string; + creator_id: string; + id: string; + item_id: string; + responder_id: string; + }; + Insert: { + created_at?: string; + creator_id: string; + id?: string; + item_id: string; + responder_id: string; + }; + Update: { + created_at?: string; + creator_id?: string; + id?: string; + item_id?: string; + responder_id?: string; + }; + Relationships: [ + { + foreignKeyName: 'thread_creator_id_fkey'; + columns: ['creator_id']; + isOneToOne: false; + referencedRelation: 'user'; + referencedColumns: ['id']; + }, + { + foreignKeyName: 'thread_item_id_fkey'; + columns: ['item_id']; + isOneToOne: false; + referencedRelation: 'item'; + referencedColumns: ['id']; + }, + { + foreignKeyName: 'thread_responder_id_fkey'; + columns: ['responder_id']; + isOneToOne: false; + referencedRelation: 'user'; + referencedColumns: ['id']; + }, + ]; + }; + user: { + Row: { + about: string | null; + avatar_url: string | null; + created_at: string; + display_name: string; + id: string; + invited_by: string | null; + vendor_id: string | null; + }; + Insert: { + about?: string | null; + avatar_url?: string | null; + created_at?: string; + display_name: string; + id?: string; + invited_by?: string | null; + vendor_id?: string | null; + }; + Update: { + about?: string | null; + avatar_url?: string | null; + created_at?: string; + display_name?: string; + id?: string; + invited_by?: string | null; + vendor_id?: string | null; + }; + Relationships: [ + { + foreignKeyName: 'user_invited_by_fkey'; + columns: ['invited_by']; + isOneToOne: false; + referencedRelation: 'user'; + referencedColumns: ['id']; + }, + ]; + }; + user_settings: { + Row: { + created_at: string; + id: string; + setting_key: string; + setting_value: Json; + updated_at: string; + user_id: string; + }; + Insert: { + created_at?: string; + id?: string; + setting_key: string; + setting_value?: Json; + updated_at?: string; + user_id: string; + }; + Update: { + created_at?: string; + id?: string; + setting_key?: string; + setting_value?: Json; + updated_at?: string; + user_id?: string; + }; + Relationships: [ + { + foreignKeyName: 'user_settings_user_id_fkey'; + columns: ['user_id']; + isOneToOne: false; + referencedRelation: 'user'; + referencedColumns: ['id']; + }, + ]; + }; + watch: { + Row: { + created_at: string; + id: string; + name: string; + notify: string | null; + query_params: string; + user_id: string; + }; + Insert: { + created_at?: string; + id?: string; + name: string; + notify?: string | null; + query_params: string; + user_id: string; + }; + Update: { + created_at?: string; + id?: string; + name?: string; + notify?: string | null; + query_params?: string; + user_id?: string; + }; + Relationships: [ + { + foreignKeyName: 'watch_notify_fkey'; + columns: ['notify']; + isOneToOne: false; + referencedRelation: 'contact_info'; + referencedColumns: ['id']; + }, + { + foreignKeyName: 'watch_user_id_fkey'; + columns: ['user_id']; + isOneToOne: false; + referencedRelation: 'user'; + referencedColumns: ['id']; + }, + ]; + }; + }; + Views: { + [_ in never]: never; + }; + Functions: { + [_ in never]: never; + }; + Enums: { + connection_status: 'pending' | 'accepted' | 'declined'; + contact_type: 'email' | 'phone'; + item_status: 'active' | 'archived' | 'deleted'; + item_type: 'buy' | 'sell'; + visibility: 'hidden' | 'connections-only' | 'public'; + }; + CompositeTypes: { + [_ in never]: never; + }; + }; +}; + +type DatabaseWithoutInternals = Omit; + +type DefaultSchema = DatabaseWithoutInternals[Extract< + keyof Database, + 'public' +>]; + +export type Tables< + DefaultSchemaTableNameOrOptions extends + | keyof (DefaultSchema['Tables'] & DefaultSchema['Views']) + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; + } + ? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Views']) + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; +} + ? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Views'])[TableName] extends { + Row: infer R; + } + ? R + : never + : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema['Tables'] & + DefaultSchema['Views']) + ? (DefaultSchema['Tables'] & + DefaultSchema['Views'])[DefaultSchemaTableNameOrOptions] extends { + Row: infer R; + } + ? R + : never + : never; + +export type TablesInsert< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema['Tables'] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'][TableName] extends { + Insert: infer I; + } + ? I + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema['Tables'] + ? DefaultSchema['Tables'][DefaultSchemaTableNameOrOptions] extends { + Insert: infer I; + } + ? I + : never + : never; + +export type TablesUpdate< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema['Tables'] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'][TableName] extends { + Update: infer U; + } + ? U + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema['Tables'] + ? DefaultSchema['Tables'][DefaultSchemaTableNameOrOptions] extends { + Update: infer U; + } + ? U + : never + : never; + +export type Enums< + DefaultSchemaEnumNameOrOptions extends + | keyof DefaultSchema['Enums'] + | { schema: keyof DatabaseWithoutInternals }, + EnumName extends DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; + } + ? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions['schema']]['Enums'] + : never = never, +> = DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; +} + ? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions['schema']]['Enums'][EnumName] + : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema['Enums'] + ? DefaultSchema['Enums'][DefaultSchemaEnumNameOrOptions] + : never; + +export type CompositeTypes< + PublicCompositeTypeNameOrOptions extends + | keyof DefaultSchema['CompositeTypes'] + | { schema: keyof DatabaseWithoutInternals }, + CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; + } + ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions['schema']]['CompositeTypes'] + : never = never, +> = PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; +} + ? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions['schema']]['CompositeTypes'][CompositeTypeName] + : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema['CompositeTypes'] + ? DefaultSchema['CompositeTypes'][PublicCompositeTypeNameOrOptions] + : never; + +export const Constants = { + graphql_public: { + Enums: {}, + }, + public: { + Enums: { + connection_status: ['pending', 'accepted', 'declined'], + contact_type: ['email', 'phone'], + item_status: ['active', 'archived', 'deleted'], + item_type: ['buy', 'sell'], + visibility: ['hidden', 'connections-only', 'public'], + }, + }, +} as const; diff --git a/src/pages/api/invites/create.ts b/src/pages/api/invites/create.ts new file mode 100644 index 0000000..f75c55f --- /dev/null +++ b/src/pages/api/invites/create.ts @@ -0,0 +1,90 @@ +import type { APIRoute } from 'astro'; +import { createSupabaseServerClient } from '../../../lib/auth'; + +export const POST: APIRoute = async ({ cookies }) => { + const supabase = createSupabaseServerClient(cookies); + + // Get current user + const { + data: { user }, + error: userError, + } = await supabase.auth.getUser(); + + if (userError || !user) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + }); + } + + // Check for invites created in the last 24 hours + const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + + const { data: recentInvites, error: queryError } = await supabase + .from('invite') + .select('created_at') + .eq('inviter_id', user.id) + .gte('created_at', oneDayAgo) + .limit(1); + + if (queryError) { + return new Response(JSON.stringify({ error: 'Database error' }), { + status: 500, + }); + } + + if (recentInvites && recentInvites.length > 0) { + return new Response( + JSON.stringify({ + error: 'You can only create one invite code every 24 hours.', + }), + { status: 429 } + ); + } + + // Generate random 8-char code with retries + // Using ambiguous-safe alphanumeric characters (excluding I, L, O, 0, 1) + const chars = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; + const maxRetries = 3; + + for (let attempt = 0; attempt < maxRetries; attempt++) { + let code = ''; + for (let i = 0; i < 8; i++) { + code += chars.charAt(Math.floor(Math.random() * chars.length)); + } + + // Insert new invite + const { data: newInvite, error: insertError } = await supabase + .from('invite') + .insert({ + inviter_id: user.id, + invite_code: code, + }) + .select() + .single(); + + if (insertError) { + // Handle potential collision + if (insertError.code === '23505') { + // Unique violation, try again + continue; + } + // Other errors are fatal + return new Response(JSON.stringify({ error: insertError.message }), { + status: 500, + }); + } + + // Success + return new Response(JSON.stringify(newInvite), { + status: 201, + }); + } + + // If we exhausted all retries + return new Response( + JSON.stringify({ + error: 'Failed to generate a unique invite code. Please try again.', + }), + { status: 500 } + ); +}; diff --git a/src/pages/api/invites/revoke.ts b/src/pages/api/invites/revoke.ts new file mode 100644 index 0000000..d385283 --- /dev/null +++ b/src/pages/api/invites/revoke.ts @@ -0,0 +1,53 @@ +import type { APIRoute } from 'astro'; +import { createSupabaseServerClient } from '../../../lib/auth'; + +export const POST: APIRoute = async ({ request, cookies }) => { + const supabase = createSupabaseServerClient(cookies); + + // Get current user + const { + data: { user }, + error: userError, + } = await supabase.auth.getUser(); + + if (userError || !user) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + }); + } + + try { + const body = await request.json(); + const { inviteId } = body; + + if (!inviteId) { + return new Response(JSON.stringify({ error: 'Invite ID is required' }), { + status: 400, + }); + } + + // Update the invite to set revoked_at + // RLS policy "Users can update invites" ensures they can only update their own + const { data, error } = await supabase + .from('invite') + .update({ revoked_at: new Date().toISOString() }) + .eq('id', inviteId) + .eq('inviter_id', user.id) // Double check ownership though RLS covers it + .select() + .single(); + + if (error) { + return new Response(JSON.stringify({ error: error.message }), { + status: 500, + }); + } + + return new Response(JSON.stringify(data), { + status: 200, + }); + } catch { + return new Response(JSON.stringify({ error: 'Invalid request' }), { + status: 400, + }); + } +}; diff --git a/src/pages/dashboard.astro b/src/pages/dashboard.astro index 36b8117..d28cf8a 100644 --- a/src/pages/dashboard.astro +++ b/src/pages/dashboard.astro @@ -60,6 +60,42 @@ const createdAt = user?.created_at + +
diff --git a/src/pages/invites.astro b/src/pages/invites.astro new file mode 100644 index 0000000..fb05b9a --- /dev/null +++ b/src/pages/invites.astro @@ -0,0 +1,46 @@ +--- +import Layout from '../layouts/Layout.astro'; +import InviteManager from '../components/react/InviteManager'; +import { createSupabaseServerClient } from '../lib/auth'; + +// Auth check handled by middleware, but we need user for data fetching +const user = Astro.locals.user; + +if (!user) { + return Astro.redirect('/auth/login'); +} + +const supabase = createSupabaseServerClient(Astro.cookies); + +// Fetch user's invites +const { data: invites, error } = await supabase + .from('invite') + .select('*') + .eq('inviter_id', user.id) + .order('created_at', { ascending: false }); + +if (error) { + console.error('Error fetching invites:', error); +} +--- + + +
+
+
+ Dashboard + / + Invites +
+

Manage Invites

+

+ Invite friends and trusted connections to join the marketplace. You can + generate one new invite code every 24 hours. +

+
+ + +
+
From 26c1fe7a56f23d44e9ce590db8070261c2ba03d0 Mon Sep 17 00:00:00 2001 From: Addison Emig Date: Thu, 20 Nov 2025 07:10:47 -0600 Subject: [PATCH 04/33] feat: use kwila cloud email addresses in seed data --- supabase/seeds/01_auth_users.sql | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/supabase/seeds/01_auth_users.sql b/supabase/seeds/01_auth_users.sql index 703900c..7f62158 100644 --- a/supabase/seeds/01_auth_users.sql +++ b/supabase/seeds/01_auth_users.sql @@ -27,7 +27,7 @@ insert into auth.users ( ( '22222222-2222-2222-2222-222222222201', '00000000-0000-0000-0000-000000000000', - 'alice@example.com', + 'alice.market@kwila.cloud', crypt('password123', gen_salt('bf')), '2025-10-20 10:00:00+00', '2025-10-20 10:00:00+00', @@ -45,7 +45,7 @@ insert into auth.users ( ( '22222222-2222-2222-2222-222222222202', '00000000-0000-0000-0000-000000000000', - 'bob@example.com', + 'bob.market@kwila.cloud', crypt('password123', gen_salt('bf')), '2025-10-25 14:30:00+00', '2025-10-25 14:30:00+00', @@ -63,7 +63,7 @@ insert into auth.users ( ( '22222222-2222-2222-2222-222222222203', '00000000-0000-0000-0000-000000000000', - 'carol@example.com', + 'carol.market@kwila.cloud', crypt('password123', gen_salt('bf')), '2025-10-30 09:15:00+00', '2025-10-30 09:15:00+00', @@ -81,7 +81,7 @@ insert into auth.users ( ( '22222222-2222-2222-2222-222222222204', '00000000-0000-0000-0000-000000000000', - 'david@example.com', + 'david.market@kwila.cloud', crypt('password123', gen_salt('bf')), '2025-11-04 11:00:00+00', '2025-11-04 11:00:00+00', @@ -99,7 +99,7 @@ insert into auth.users ( ( '22222222-2222-2222-2222-222222222205', '00000000-0000-0000-0000-000000000000', - 'eve@example.com', + 'eve.market@kwila.cloud', crypt('password123', gen_salt('bf')), '2025-11-09 16:45:00+00', '2025-11-09 16:45:00+00', @@ -133,7 +133,7 @@ insert into auth.identities ( '22222222-2222-2222-2222-222222222201', '22222222-2222-2222-2222-222222222201', 'email', - '{"sub": "22222222-2222-2222-2222-222222222201", "email": "alice@example.com", "email_verified": true, "phone_verified": false}', + '{"sub": "22222222-2222-2222-2222-222222222201", "email": "alice.market@kwila.cloud", "email_verified": true, "phone_verified": false}', '2025-10-20 10:00:00+00', '2025-10-20 10:00:00+00', '2025-10-20 10:00:00+00' @@ -144,7 +144,7 @@ insert into auth.identities ( '22222222-2222-2222-2222-222222222202', '22222222-2222-2222-2222-222222222202', 'email', - '{"sub": "22222222-2222-2222-2222-222222222202", "email": "bob@example.com", "email_verified": true, "phone_verified": false}', + '{"sub": "22222222-2222-2222-2222-222222222202", "email": "bob.market@kwila.cloud", "email_verified": true, "phone_verified": false}', '2025-10-25 14:30:00+00', '2025-10-25 14:30:00+00', '2025-10-25 14:30:00+00' @@ -155,7 +155,7 @@ insert into auth.identities ( '22222222-2222-2222-2222-222222222203', '22222222-2222-2222-2222-222222222203', 'email', - '{"sub": "22222222-2222-2222-2222-222222222203", "email": "carol@example.com", "email_verified": true, "phone_verified": false}', + '{"sub": "22222222-2222-2222-2222-222222222203", "email": "carol.market@kwila.cloud", "email_verified": true, "phone_verified": false}', '2025-10-30 09:15:00+00', '2025-10-30 09:15:00+00', '2025-10-30 09:15:00+00' @@ -166,7 +166,7 @@ insert into auth.identities ( '22222222-2222-2222-2222-222222222204', '22222222-2222-2222-2222-222222222204', 'email', - '{"sub": "22222222-2222-2222-2222-222222222204", "email": "david@example.com", "email_verified": true, "phone_verified": false}', + '{"sub": "22222222-2222-2222-2222-222222222204", "email": "david.market@kwila.cloud", "email_verified": true, "phone_verified": false}', '2025-11-04 11:00:00+00', '2025-11-04 11:00:00+00', '2025-11-04 11:00:00+00' @@ -177,7 +177,7 @@ insert into auth.identities ( '22222222-2222-2222-2222-222222222205', '22222222-2222-2222-2222-222222222205', 'email', - '{"sub": "22222222-2222-2222-2222-222222222205", "email": "eve@example.com", "email_verified": true, "phone_verified": false}', + '{"sub": "22222222-2222-2222-2222-222222222205", "email": "eve.market@kwila.cloud", "email_verified": true, "phone_verified": false}', '2025-11-09 16:45:00+00', '2025-11-09 16:45:00+00', '2025-11-09 16:45:00+00' From 0b7e5293294c86152dcaf97c95c504c99f863df3 Mon Sep 17 00:00:00 2001 From: Addison Emig Date: Thu, 20 Nov 2025 07:29:33 -0600 Subject: [PATCH 05/33] fix: credentials --- src/components/react/InviteManager.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/react/InviteManager.tsx b/src/components/react/InviteManager.tsx index abc1141..36269fd 100644 --- a/src/components/react/InviteManager.tsx +++ b/src/components/react/InviteManager.tsx @@ -32,6 +32,7 @@ export default function InviteManager({ initialInvites }: InviteManagerProps) { try { const res = await fetch('/api/invites/create', { method: 'POST', + credentials: 'include', }); if (!res.ok) { @@ -62,6 +63,7 @@ export default function InviteManager({ initialInvites }: InviteManagerProps) { 'Content-Type': 'application/json', }, body: JSON.stringify({ inviteId }), + credentials: 'include', }); if (!res.ok) { From c282d7dc67dc23f220c8065875067e6bb9bc3610 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 20 Nov 2025 13:44:46 +0000 Subject: [PATCH 06/33] Invite history styles now match Co-authored-by: Pertempto --- src/components/react/InviteManager.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/react/InviteManager.tsx b/src/components/react/InviteManager.tsx index 36269fd..8e7a1ed 100644 --- a/src/components/react/InviteManager.tsx +++ b/src/components/react/InviteManager.tsx @@ -175,15 +175,15 @@ export default function InviteManager({ initialInvites }: InviteManagerProps) { )}
-
-

- History -

+
+
+

History

+
{pastInvites.length === 0 ? (

No past invites.

) : ( -
- +
+
From a792ef2f58b4fe1d9d45f80c841e85684ecc9f47 Mon Sep 17 00:00:00 2001 From: Addison Emig Date: Thu, 20 Nov 2025 07:38:46 -0600 Subject: [PATCH 07/33] feat: debug comment author --- .github/workflows/opencode.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index a993fa3..917caea 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -1,4 +1,4 @@ -name: opencode +name: OpenCode Agent on: issue_comment: @@ -13,7 +13,8 @@ jobs: startsWith(github.event.comment.body, '/oc') || contains(github.event.comment.body, ' /opencode') || startsWith(github.event.comment.body, '/opencode') - ) && github.event.comment.author_association == 'OWNER' + ) + # && github.event.comment.author_association == 'OWNER' runs-on: ubuntu-latest permissions: id-token: write @@ -25,6 +26,11 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + - name: Debug author association + run: | + echo "Comment body: ${{ github.event.comment.body }}" + echo "Author association: ${{ github.event.comment.author_association }}" + - name: Run opencode uses: sst/opencode/github@latest env: From 49ce7dc9cb3e9f63743afeb98cfb8257c7d6f5d4 Mon Sep 17 00:00:00 2001 From: Addison Emig Date: Thu, 20 Nov 2025 07:39:50 -0600 Subject: [PATCH 08/33] fix: syntax --- .github/workflows/opencode.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index 917caea..45f9d51 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -14,7 +14,7 @@ jobs: contains(github.event.comment.body, ' /opencode') || startsWith(github.event.comment.body, '/opencode') ) - # && github.event.comment.author_association == 'OWNER' + # && github.event.comment.author_association == 'OWNER' runs-on: ubuntu-latest permissions: id-token: write From fee916b52605fe47331bc01d061086b0c51ae0b3 Mon Sep 17 00:00:00 2001 From: Addison Emig Date: Thu, 20 Nov 2025 07:41:50 -0600 Subject: [PATCH 09/33] fix: author association --- .github/workflows/opencode.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index 45f9d51..d222541 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -14,7 +14,11 @@ jobs: contains(github.event.comment.body, ' /opencode') || startsWith(github.event.comment.body, '/opencode') ) - # && github.event.comment.author_association == 'OWNER' + && + ( + github.event.comment.author_association == 'OWNER' || + github.event.comment.author_association == 'MEMBER' + ) runs-on: ubuntu-latest permissions: id-token: write From c92c58c296a4b11c02cc420cabb767363eeee812 Mon Sep 17 00:00:00 2001 From: Addison Emig Date: Thu, 20 Nov 2025 07:55:05 -0600 Subject: [PATCH 10/33] fix: create invite --- specs/000-mvp.md | 8 +++++++- src/components/react/InviteManager.tsx | 25 +++++++++++++++++++++++-- src/lib/auth.ts | 25 +++++++++++++++++++------ src/pages/api/invites/create.ts | 5 +++-- src/pages/api/invites/revoke.ts | 3 ++- src/pages/invites.astro | 6 +++++- 6 files changed, 59 insertions(+), 13 deletions(-) diff --git a/specs/000-mvp.md b/specs/000-mvp.md index bad68f0..2758fa2 100644 --- a/specs/000-mvp.md +++ b/specs/000-mvp.md @@ -114,6 +114,12 @@ This document contains the basic roadmap for the minimal viable product (MVP). - [x] Add dedicated page for invite code - [x] List status of user's invite codes - [x] Allow user to create new invite codes + - [ ] Include clear warning about only inviting trusted people with which you have an in-person relationship + - [ ] Require the user to input invitee's name + - [ ] Save invitee's name with invite code in new column "name" + - [ ] Require the user to check a box "I have met this person in person multiple times" + - [ ] Require the user to check a box "I agree to allow this person to have access to my Contacts-Only information" + - [ ] Do not allow user to create invite code without checking both boxes and entering the invitee's name - [ ] **Build signup flow** - [ ] Create signup page @@ -122,7 +128,7 @@ This document contains the basic roadmap for the minimal viable product (MVP). - [ ] Add new accepted connection between two users - [ ] Build basic onboarding flow - [ ] Contact visibility settings - - [ ] Display Name + - [ ] Display Name (default to value from invite code) - [ ] Bio information - [ ] For now - use https://www.dicebear.com/styles/initials/ to automatically generate and upload avatar for new user diff --git a/src/components/react/InviteManager.tsx b/src/components/react/InviteManager.tsx index 8e7a1ed..1d7e581 100644 --- a/src/components/react/InviteManager.tsx +++ b/src/components/react/InviteManager.tsx @@ -1,5 +1,6 @@ import React, { useState } from 'react'; import type { Tables } from '../../lib/database.types'; +import { createSupabaseBrowserClient } from '../../lib/auth'; type Invite = Tables<'invite'>; @@ -30,9 +31,20 @@ export default function InviteManager({ initialInvites }: InviteManagerProps) { setLoading(true); setError(null); try { + const supabase = createSupabaseBrowserClient(); + const { + data: { session }, + } = await supabase.auth.getSession(); + + if (!session) { + throw new Error('You must be logged in to create invites'); + } + const res = await fetch('/api/invites/create', { method: 'POST', - credentials: 'include', + headers: { + Authorization: `Bearer ${session.access_token}`, + }, }); if (!res.ok) { @@ -57,13 +69,22 @@ export default function InviteManager({ initialInvites }: InviteManagerProps) { if (!confirm('Are you sure you want to revoke this invite code?')) return; try { + const supabase = createSupabaseBrowserClient(); + const { + data: { session }, + } = await supabase.auth.getSession(); + + if (!session) { + throw new Error('You must be logged in to revoke invites'); + } + const res = await fetch('/api/invites/revoke', { method: 'POST', headers: { 'Content-Type': 'application/json', + Authorization: `Bearer ${session.access_token}`, }, body: JSON.stringify({ inviteId }), - credentials: 'include', }); if (!res.ok) { diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 7ea9111..eedde44 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -14,9 +14,11 @@ const supabaseAnonKey = import.meta.env.PUBLIC_SUPABASE_ANON_KEY; */ export function createSupabaseServerClient( cookies: AstroCookies, - cookieHeader?: string | null + cookieHeader?: string | null, + authHeader?: string | null ) { - return createServerClient(supabaseUrl, supabaseAnonKey, { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const config: any = { cookies: { getAll() { const parsed = parseCookieHeader(cookieHeader ?? ''); @@ -28,7 +30,8 @@ export function createSupabaseServerClient( ) .map((cookie) => ({ name: cookie.name, value: cookie.value })); }, - setAll(cookiesToSet) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + setAll(cookiesToSet: any) { for (const { name, value, options } of cookiesToSet) { cookies.set(name, value, { path: '/', @@ -40,7 +43,17 @@ export function createSupabaseServerClient( } }, }, - }); + }; + + if (authHeader) { + config.global = { + headers: { + Authorization: authHeader, + }, + }; + } + + return createServerClient(supabaseUrl, supabaseAnonKey, config); } /** @@ -58,7 +71,7 @@ export async function getSession( cookies: AstroCookies, cookieHeader?: string | null ) { - const supabase = createSupabaseServerClient(cookies, cookieHeader); + const supabase = createSupabaseServerClient(cookies, cookieHeader, undefined); const { data: { session }, error, @@ -80,7 +93,7 @@ export async function getUser( cookies: AstroCookies, cookieHeader?: string | null ) { - const supabase = createSupabaseServerClient(cookies, cookieHeader); + const supabase = createSupabaseServerClient(cookies, cookieHeader, undefined); const { data: { user }, error, diff --git a/src/pages/api/invites/create.ts b/src/pages/api/invites/create.ts index f75c55f..8e09dd2 100644 --- a/src/pages/api/invites/create.ts +++ b/src/pages/api/invites/create.ts @@ -1,8 +1,9 @@ import type { APIRoute } from 'astro'; import { createSupabaseServerClient } from '../../../lib/auth'; -export const POST: APIRoute = async ({ cookies }) => { - const supabase = createSupabaseServerClient(cookies); +export const POST: APIRoute = async ({ cookies, request }) => { + const authHeader = request.headers.get('Authorization'); + const supabase = createSupabaseServerClient(cookies, undefined, authHeader); // Get current user const { diff --git a/src/pages/api/invites/revoke.ts b/src/pages/api/invites/revoke.ts index d385283..9b54ea6 100644 --- a/src/pages/api/invites/revoke.ts +++ b/src/pages/api/invites/revoke.ts @@ -2,7 +2,8 @@ import type { APIRoute } from 'astro'; import { createSupabaseServerClient } from '../../../lib/auth'; export const POST: APIRoute = async ({ request, cookies }) => { - const supabase = createSupabaseServerClient(cookies); + const authHeader = request.headers.get('Authorization'); + const supabase = createSupabaseServerClient(cookies, undefined, authHeader); // Get current user const { diff --git a/src/pages/invites.astro b/src/pages/invites.astro index fb05b9a..a66860d 100644 --- a/src/pages/invites.astro +++ b/src/pages/invites.astro @@ -10,7 +10,11 @@ if (!user) { return Astro.redirect('/auth/login'); } -const supabase = createSupabaseServerClient(Astro.cookies); +const supabase = createSupabaseServerClient( + Astro.cookies, + undefined, + undefined +); // Fetch user's invites const { data: invites, error } = await supabase From 4a233be25abe2f36cfab0247c15bdfe7d269f904 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 20 Nov 2025 14:04:21 +0000 Subject: [PATCH 11/33] New reusable breadcrumb layout Co-authored-by: Pertempto --- src/components/astro/Breadcrumbs.astro | 38 +++++++++++++++++++++ src/layouts/PageLayoutWithBreadcrumbs.astro | 20 +++++++++++ src/pages/invites.astro | 35 +++++++++---------- 3 files changed, 75 insertions(+), 18 deletions(-) create mode 100644 src/components/astro/Breadcrumbs.astro create mode 100644 src/layouts/PageLayoutWithBreadcrumbs.astro diff --git a/src/components/astro/Breadcrumbs.astro b/src/components/astro/Breadcrumbs.astro new file mode 100644 index 0000000..39dfb3e --- /dev/null +++ b/src/components/astro/Breadcrumbs.astro @@ -0,0 +1,38 @@ +--- +export interface BreadcrumbItem { + label: string; + href?: string; +} + +export interface Props { + items: BreadcrumbItem[]; + class?: string; +} + +const { items = [], class: className = '' } = Astro.props; +--- +{items.length > 0 && ( + +)} diff --git a/src/layouts/PageLayoutWithBreadcrumbs.astro b/src/layouts/PageLayoutWithBreadcrumbs.astro new file mode 100644 index 0000000..32e6614 --- /dev/null +++ b/src/layouts/PageLayoutWithBreadcrumbs.astro @@ -0,0 +1,20 @@ +--- +import Layout, { type Props as LayoutProps } from './Layout.astro'; +import Breadcrumbs, { + type BreadcrumbItem, +} from '../components/astro/Breadcrumbs.astro'; + +interface Props extends LayoutProps { + breadcrumbs: BreadcrumbItem[]; +} + +const { breadcrumbs, ...layoutProps } = Astro.props; +--- + + +
+ + +
+ +
diff --git a/src/pages/invites.astro b/src/pages/invites.astro index a66860d..7d605e1 100644 --- a/src/pages/invites.astro +++ b/src/pages/invites.astro @@ -1,5 +1,5 @@ --- -import Layout from '../layouts/Layout.astro'; +import PageLayoutWithBreadcrumbs from '../layouts/PageLayoutWithBreadcrumbs.astro'; import InviteManager from '../components/react/InviteManager'; import { createSupabaseServerClient } from '../lib/auth'; @@ -28,23 +28,22 @@ if (error) { } --- - -
-
-
- Dashboard - / - Invites -
-

Manage Invites

-

- Invite friends and trusted connections to join the marketplace. You can - generate one new invite code every 24 hours. -

-
+ +
+

Manage Invites

+

+ Invite friends and trusted connections to join the marketplace. You can + generate one new invite code every 24 hours. +

+
+
- +
From cd4ad625f736b5e0c045a15059c48d48b35fa6fd Mon Sep 17 00:00:00 2001 From: Addison Emig Date: Thu, 20 Nov 2025 08:49:10 -0600 Subject: [PATCH 12/33] fix: remove unnecessary wrapper --- src/components/astro/Breadcrumbs.astro | 59 +++++++++++++++----------- src/pages/invites.astro | 4 +- 2 files changed, 36 insertions(+), 27 deletions(-) diff --git a/src/components/astro/Breadcrumbs.astro b/src/components/astro/Breadcrumbs.astro index 39dfb3e..1500ec5 100644 --- a/src/components/astro/Breadcrumbs.astro +++ b/src/components/astro/Breadcrumbs.astro @@ -11,28 +11,39 @@ export interface Props { const { items = [], class: className = '' } = Astro.props; --- -{items.length > 0 && ( - -)} +{ + items.length > 0 && ( + + ) +} diff --git a/src/pages/invites.astro b/src/pages/invites.astro index 7d605e1..cd70837 100644 --- a/src/pages/invites.astro +++ b/src/pages/invites.astro @@ -43,7 +43,5 @@ if (error) {

-
- -
+ From a10b676f6ea6f7a752cbab0cc7f5f9f91ced24d4 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 20 Nov 2025 14:56:10 +0000 Subject: [PATCH 13/33] Page headers unified Co-authored-by: Pertempto --- src/components/astro/PageHeader.astro | 25 +++++++++++++++++++++++++ src/pages/dashboard.astro | 21 ++++++++------------- src/pages/invites.astro | 13 ++++++------- 3 files changed, 39 insertions(+), 20 deletions(-) create mode 100644 src/components/astro/PageHeader.astro diff --git a/src/components/astro/PageHeader.astro b/src/components/astro/PageHeader.astro new file mode 100644 index 0000000..a89c4dd --- /dev/null +++ b/src/components/astro/PageHeader.astro @@ -0,0 +1,25 @@ +--- +export interface Props { + title: string; + description?: string; + class?: string; +} + +const { title, description, class: className = '' } = Astro.props; +const hasAction = Astro.slots.has('action'); +--- + +
+
+

{title}

+ {description &&

{description}

} +
+ + {hasAction && ( +
+ +
+ )} +
diff --git a/src/pages/dashboard.astro b/src/pages/dashboard.astro index d28cf8a..d77cd89 100644 --- a/src/pages/dashboard.astro +++ b/src/pages/dashboard.astro @@ -1,6 +1,7 @@ --- import Layout from '../layouts/Layout.astro'; import LogoutButton from '../components/react/LogoutButton'; +import PageHeader from '../components/astro/PageHeader.astro'; // User is set by middleware for protected routes const user = Astro.locals.user; @@ -17,10 +18,13 @@ const createdAt = user?.created_at
-
-

Dashboard

- -
+ + +
- -
- -
-

- This is a basic dashboard for authenticated users. More features will - be added as development progresses. -

-
diff --git a/src/pages/invites.astro b/src/pages/invites.astro index cd70837..17a17a2 100644 --- a/src/pages/invites.astro +++ b/src/pages/invites.astro @@ -1,6 +1,7 @@ --- import PageLayoutWithBreadcrumbs from '../layouts/PageLayoutWithBreadcrumbs.astro'; import InviteManager from '../components/react/InviteManager'; +import PageHeader from '../components/astro/PageHeader.astro'; import { createSupabaseServerClient } from '../lib/auth'; // Auth check handled by middleware, but we need user for data fetching @@ -35,13 +36,11 @@ if (error) { { label: 'Invites' }, ]} > -
-

Manage Invites

-

- Invite friends and trusted connections to join the marketplace. You can - generate one new invite code every 24 hours. -

-
+ From 1e8348842b586ab0ff1d49c5e5a93d0db3087085 Mon Sep 17 00:00:00 2001 From: Addison Emig Date: Thu, 20 Nov 2025 08:55:34 -0600 Subject: [PATCH 14/33] docs: more tasks --- specs/000-mvp.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/specs/000-mvp.md b/specs/000-mvp.md index 2758fa2..ecc3689 100644 --- a/specs/000-mvp.md +++ b/specs/000-mvp.md @@ -114,6 +114,8 @@ This document contains the basic roadmap for the minimal viable product (MVP). - [x] Add dedicated page for invite code - [x] List status of user's invite codes - [x] Allow user to create new invite codes + - [ ] Use our Button component on invite page + - [ ] Include revoked invites in the history section - [ ] Include clear warning about only inviting trusted people with which you have an in-person relationship - [ ] Require the user to input invitee's name - [ ] Save invitee's name with invite code in new column "name" From b5608da625090029396800fb662aeb4ba574ef40 Mon Sep 17 00:00:00 2001 From: Addison Emig Date: Thu, 20 Nov 2025 09:04:02 -0600 Subject: [PATCH 15/33] feat: improve dashboard styling --- src/components/astro/PageHeader.astro | 12 ++- src/pages/dashboard.astro | 150 +++++++++++++------------- 2 files changed, 80 insertions(+), 82 deletions(-) diff --git a/src/components/astro/PageHeader.astro b/src/components/astro/PageHeader.astro index a89c4dd..d4c9ff8 100644 --- a/src/components/astro/PageHeader.astro +++ b/src/components/astro/PageHeader.astro @@ -17,9 +17,11 @@ const hasAction = Astro.slots.has('action'); {description &&

{description}

} - {hasAction && ( -
- -
- )} + { + hasAction && ( +
+ +
+ ) + } diff --git a/src/pages/dashboard.astro b/src/pages/dashboard.astro index d77cd89..807ba90 100644 --- a/src/pages/dashboard.astro +++ b/src/pages/dashboard.astro @@ -17,87 +17,83 @@ const createdAt = user?.created_at --- -
- - - + + + -
-
-

- Account Information -

-
-
-
Email
-
{user?.email || 'Not set'}
-
-
-
User ID
-
- {user?.id || 'Unknown'} -
-
-
-
Account Created
-
{createdAt}
-
-
-
Last Sign In
-
- { - user?.last_sign_in_at - ? new Date(user.last_sign_in_at).toLocaleString('en-US', { - dateStyle: 'medium', - timeStyle: 'short', - }) - : 'Unknown' - } -
-
-
-
+
+
+

+ Account Information +

+
+
+
Email
+
{user?.email || 'Not set'}
+
+
+
User ID
+
+ {user?.id || 'Unknown'} +
+
+
+
Account Created
+
{createdAt}
+
+
+
Last Sign In
+
+ { + user?.last_sign_in_at + ? new Date(user.last_sign_in_at).toLocaleString('en-US', { + dateStyle: 'medium', + timeStyle: 'short', + }) + : 'Unknown' + } +
+
+
+
- From c123e4174297e87ca829fc676baa43cb8b164c7d Mon Sep 17 00:00:00 2001 From: Addison Emig Date: Thu, 20 Nov 2025 09:05:29 -0600 Subject: [PATCH 16/33] docs: add more specs --- specs/000-mvp.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/specs/000-mvp.md b/specs/000-mvp.md index ecc3689..b9483e8 100644 --- a/specs/000-mvp.md +++ b/specs/000-mvp.md @@ -115,6 +115,10 @@ This document contains the basic roadmap for the minimal viable product (MVP). - [x] List status of user's invite codes - [x] Allow user to create new invite codes - [ ] Use our Button component on invite page + - [ ] Add new Card component + - [ ] Use it on the dashboard page + - [ ] Use it in the invite manager + - [ ] Use it in the core values component - [ ] Include revoked invites in the history section - [ ] Include clear warning about only inviting trusted people with which you have an in-person relationship - [ ] Require the user to input invitee's name From 4d073151e5be1f25f3226328baa0b6e11c7a2c8c Mon Sep 17 00:00:00 2001 From: Addison Emig Date: Thu, 20 Nov 2025 09:06:13 -0600 Subject: [PATCH 17/33] docs: add detail --- specs/000-mvp.md | 1 + 1 file changed, 1 insertion(+) diff --git a/specs/000-mvp.md b/specs/000-mvp.md index b9483e8..98bf454 100644 --- a/specs/000-mvp.md +++ b/specs/000-mvp.md @@ -116,6 +116,7 @@ This document contains the basic roadmap for the minimal viable product (MVP). - [x] Allow user to create new invite codes - [ ] Use our Button component on invite page - [ ] Add new Card component + - [ ] Include title property (always shown in h2) - [ ] Use it on the dashboard page - [ ] Use it in the invite manager - [ ] Use it in the core values component From 7c93027141e35d37024c58e92e32b3cc16f03fc6 Mon Sep 17 00:00:00 2001 From: Addison Emig Date: Thu, 20 Nov 2025 09:07:20 -0600 Subject: [PATCH 18/33] docs: another task --- specs/000-mvp.md | 1 + 1 file changed, 1 insertion(+) diff --git a/specs/000-mvp.md b/specs/000-mvp.md index 98bf454..5c0eaf2 100644 --- a/specs/000-mvp.md +++ b/specs/000-mvp.md @@ -120,6 +120,7 @@ This document contains the basic roadmap for the minimal viable product (MVP). - [ ] Use it on the dashboard page - [ ] Use it in the invite manager - [ ] Use it in the core values component + - [ ] Move "Quick Actions" to their own Card, above the "Account Information" card - [ ] Include revoked invites in the history section - [ ] Include clear warning about only inviting trusted people with which you have an in-person relationship - [ ] Require the user to input invitee's name From b3cfbaaf1ef70ce0b4f0af7222185a5bef903f3d Mon Sep 17 00:00:00 2001 From: Addison Emig Date: Thu, 20 Nov 2025 11:44:38 -0600 Subject: [PATCH 19/33] refactor: use Button component in invite page --- AGENTS.md | 1 + ARCHITECTURE.md | 1 - package-lock.json | 8 +++---- package.json | 2 +- src/components/react/InviteManager.tsx | 32 +++++++++++++------------- 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7919ba4..d99f10e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,6 +67,7 @@ This is a request-driven marketplace that prioritizes relationships over profit. - **Use TypeScript strict mode** - **Don't modify git config** - **Always use pre-existing layouts** from `src/layouts/` for page structure consistency +- **Always use pre-existing component** from `src/components/` for UI consistency and less duplicate code ### Contribution Process diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f66ae5c..3cd7f7f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -300,7 +300,6 @@ Every feature should support both power users and those who rank low on the "tec 1. If < 24 hours: shows limit message 1. If eligible: generates 8-character code 1. Creates invite record -1. Returns link: `/signup?code=CODE` 1. User can revoke anytime (sets revoked_at) ## Site Structure diff --git a/package-lock.json b/package-lock.json index 74a0b31..0963d12 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,7 +48,7 @@ "postcss": "^8.5.6", "prettier": "^3.6.2", "prettier-plugin-astro": "^0.14.1", - "supabase": "^2.58.5", + "supabase": "^2.60.2", "tailwindcss": "^4.1.17", "vitest": "^4.0.9", "wrangler": "^4.46.0" @@ -15606,9 +15606,9 @@ } }, "node_modules/supabase": { - "version": "2.58.5", - "resolved": "https://registry.npmjs.org/supabase/-/supabase-2.58.5.tgz", - "integrity": "sha512-mYZSkUIePTdmwlHd26Pff8wpmjfre8gcuWzrc5QqhZgZvCXugVzAQQhcjaQisw5kusbPQWNIjUwcHYEKmejhPw==", + "version": "2.60.2", + "resolved": "https://registry.npmjs.org/supabase/-/supabase-2.60.2.tgz", + "integrity": "sha512-FXSLbyeTrW37AeNhh/NrRf8GFJpksgtCmOuJl9hdqSpPJsPukINNrl+X8q5YmYCz8d8TJ5sOep2dvZtWg+SM+w==", "dev": true, "hasInstallScript": true, "license": "MIT", diff --git a/package.json b/package.json index a5e332c..2a30504 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "postcss": "^8.5.6", "prettier": "^3.6.2", "prettier-plugin-astro": "^0.14.1", - "supabase": "^2.58.5", + "supabase": "^2.60.2", "tailwindcss": "^4.1.17", "vitest": "^4.0.9", "wrangler": "^4.46.0" diff --git a/src/components/react/InviteManager.tsx b/src/components/react/InviteManager.tsx index 1d7e581..e1b4759 100644 --- a/src/components/react/InviteManager.tsx +++ b/src/components/react/InviteManager.tsx @@ -1,4 +1,5 @@ import React, { useState } from 'react'; +import Button from './Button'; import type { Tables } from '../../lib/database.types'; import { createSupabaseBrowserClient } from '../../lib/auth'; @@ -118,7 +119,7 @@ export default function InviteManager({ initialInvites }: InviteManagerProps) { await navigator.share({ title: 'Join Market', text: `Join me on Market! Use my invite code: ${code}`, - url: window.location.origin + '/auth/login', // Or a signup specific page if it existed + url: window.location.origin + '/auth/signup?code=' + code, }); } catch (err) { console.error('Error sharing:', err); @@ -136,13 +137,9 @@ export default function InviteManager({ initialInvites }: InviteManagerProps) {

Active Invites

- +
{error && ( @@ -171,24 +168,27 @@ export default function InviteManager({ initialInvites }: InviteManagerProps) {
- - - +
))} From afda1ac24d3cb6893cd04dd06b94051474f836cb Mon Sep 17 00:00:00 2001 From: Addison Emig Date: Thu, 20 Nov 2025 15:01:40 -0600 Subject: [PATCH 20/33] fix: loading data --- .opencode/command/do-pr.md | 2 +- package-lock.json | 8 +-- package.json | 2 +- specs/000-mvp.md | 6 +++ src/components/react/Button.tsx | 4 +- src/components/react/InviteManager.tsx | 27 ++++++----- src/lib/auth.ts | 27 ++++++++--- src/pages/invites.astro | 4 +- src/styles/themes.css | 67 ++++++++++++++++++++++++++ 9 files changed, 119 insertions(+), 28 deletions(-) diff --git a/.opencode/command/do-pr.md b/.opencode/command/do-pr.md index 0c8ee72..5323f31 100644 --- a/.opencode/command/do-pr.md +++ b/.opencode/command/do-pr.md @@ -19,7 +19,7 @@ Required behavior (non-interactive flow) - Run the relevant automated tests immediately after implementing the change. Tests must be run and pass before committing. - If a change only affects unit tests, run the narrower set of packages to save time. - If tests fail, refine the code until tests pass. Do not proceed to committing that TODO item until its tests pass. - - Once tests pass, update the spec (check off corresponding item) and commit the change locally using a descriptive conventional commit message (example `feat(7): add backup script`). + - Once tests pass, update the spec (check off corresponding item) and commit the change locally using a descriptive conventional commit message (example `feat: add backup script`). - Use: `git add -A && git commit -m ": "` 3. After all task items for the current section are completed and committed locally: - Push the branch to the remote: diff --git a/package-lock.json b/package-lock.json index 0963d12..74a0b31 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,7 +48,7 @@ "postcss": "^8.5.6", "prettier": "^3.6.2", "prettier-plugin-astro": "^0.14.1", - "supabase": "^2.60.2", + "supabase": "^2.58.5", "tailwindcss": "^4.1.17", "vitest": "^4.0.9", "wrangler": "^4.46.0" @@ -15606,9 +15606,9 @@ } }, "node_modules/supabase": { - "version": "2.60.2", - "resolved": "https://registry.npmjs.org/supabase/-/supabase-2.60.2.tgz", - "integrity": "sha512-FXSLbyeTrW37AeNhh/NrRf8GFJpksgtCmOuJl9hdqSpPJsPukINNrl+X8q5YmYCz8d8TJ5sOep2dvZtWg+SM+w==", + "version": "2.58.5", + "resolved": "https://registry.npmjs.org/supabase/-/supabase-2.58.5.tgz", + "integrity": "sha512-mYZSkUIePTdmwlHd26Pff8wpmjfre8gcuWzrc5QqhZgZvCXugVzAQQhcjaQisw5kusbPQWNIjUwcHYEKmejhPw==", "dev": true, "hasInstallScript": true, "license": "MIT", diff --git a/package.json b/package.json index 2a30504..a5e332c 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "postcss": "^8.5.6", "prettier": "^3.6.2", "prettier-plugin-astro": "^0.14.1", - "supabase": "^2.60.2", + "supabase": "^2.58.5", "tailwindcss": "^4.1.17", "vitest": "^4.0.9", "wrangler": "^4.46.0" diff --git a/specs/000-mvp.md b/specs/000-mvp.md index 5c0eaf2..5b4170a 100644 --- a/specs/000-mvp.md +++ b/specs/000-mvp.md @@ -215,6 +215,12 @@ This document contains the basic roadmap for the minimal viable product (MVP). - [ ] **Add error handling and user feedback** - [ ] Implement comprehensive error boundaries + - [ ] Consistent error styling + - [ ] Use error color scheme rather than hardcoded red color scheme + - [ ] Shared components for error alerts + - [ ] Login form + - [ ] Invite manager + - [ ] Where else? - [ ] Add loading states and progress indicators - [ ] Create user-friendly error messages - [ ] Add form validation feedback diff --git a/src/components/react/Button.tsx b/src/components/react/Button.tsx index e51cd44..122f9c4 100644 --- a/src/components/react/Button.tsx +++ b/src/components/react/Button.tsx @@ -4,7 +4,7 @@ import type { ReactNode, } from 'react'; -export type ButtonVariant = 'primary' | 'secondary' | 'neutral'; +export type ButtonVariant = 'primary' | 'secondary' | 'neutral' | 'danger'; type BaseProps = { variant?: ButtonVariant; @@ -32,6 +32,8 @@ const variantStyles: Record = { 'bg-secondary hover:bg-secondary-600 text-white focus:ring-secondary focus:ring-offset-surface-elevated', neutral: 'bg-surface-border hover:bg-surface-elevated text-neutral-200 focus:ring-neutral-500 focus:ring-offset-surface', + danger: + 'bg-error hover:bg-error-600 text-white focus:ring-error focus:ring-offset-surface', }; export default function Button(props: ButtonProps) { diff --git a/src/components/react/InviteManager.tsx b/src/components/react/InviteManager.tsx index e1b4759..9282385 100644 --- a/src/components/react/InviteManager.tsx +++ b/src/components/react/InviteManager.tsx @@ -54,7 +54,7 @@ export default function InviteManager({ initialInvites }: InviteManagerProps) { } const newInvite = await res.json(); - setInvites([newInvite, ...invites]); + setInvites((prev) => [newInvite, ...prev]); } catch (err) { if (err instanceof Error) { setError(err.message); @@ -94,10 +94,8 @@ export default function InviteManager({ initialInvites }: InviteManagerProps) { } const updatedInvite = await res.json(); - setInvites( - invites.map((inv) => - inv.id === updatedInvite.id ? updatedInvite : inv - ) + setInvites((prev) => + prev.map((inv) => (inv.id === updatedInvite.id ? updatedInvite : inv)) ); } catch (err) { if (err instanceof Error) { @@ -108,9 +106,14 @@ export default function InviteManager({ initialInvites }: InviteManagerProps) { } }; - const copyCode = (code: string) => { - navigator.clipboard.writeText(code); - alert('Invite code copied to clipboard!'); + const copyCode = async (code: string) => { + try { + await navigator.clipboard.writeText(code); + alert('Invite code copied to clipboard!'); + } catch (err) { + console.error('Failed to copy invite code:', err); + alert('Failed to copy invite code. Please try again.'); + } }; const shareCode = async (code: string) => { @@ -125,7 +128,7 @@ export default function InviteManager({ initialInvites }: InviteManagerProps) { console.error('Error sharing:', err); } } else { - copyCode(code); + await copyCode(code); } }; @@ -143,7 +146,7 @@ export default function InviteManager({ initialInvites }: InviteManagerProps) { {error && ( -
+
{error}
)} @@ -176,14 +179,14 @@ export default function InviteManager({ initialInvites }: InviteManagerProps) { Copy -
+ +
+
+ +
- {error && ( -
- {error} -
- )} + {error && ( +
+ {error} +
+ )} - {activeInvites.length === 0 ? ( -

No active invite codes.

- ) : ( -
- {activeInvites.map((invite) => ( -
-
-
- {invite.invite_code} -
-
- Created:{' '} - {new Date(invite.created_at).toLocaleDateString()} -
+ {activeInvites.length === 0 ? ( +

No active invite codes.

+ ) : ( +
+ {activeInvites.map((invite) => ( +
+
+
+ {invite.invite_code}
- -
- - - +
+ Created: {new Date(invite.created_at).toLocaleDateString()}
- ))} -
- )} -
-
-
-

History

+
+ + + +
+
+ ))}
- {pastInvites.length === 0 ? ( -

No past invites.

- ) : ( -
-
Code
- - - - - - - - - {pastInvites.map((invite) => { - let status = 'Unknown'; - let date = invite.created_at; + )} + - if (invite.revoked_at) { - status = 'Revoked'; - date = invite.revoked_at; - } else if (invite.used_at) { - status = 'Used'; - date = invite.used_at; - } + + {pastInvites.length === 0 ? ( +

No past invites.

+ ) : ( +
+
CodeStatusDate
+ + + + + + + + + {pastInvites.map((invite) => { + let status = 'Unknown'; + let date = invite.created_at; - return ( - - - - - - ); - })} - -
CodeStatusDate
- {invite.invite_code} - - - {status} - - - {new Date(date).toLocaleDateString()} -
-
- )} -
-
+ if (invite.revoked_at) { + status = 'Revoked'; + date = invite.revoked_at; + } else if (invite.used_at) { + status = 'Used'; + date = invite.used_at; + } + + return ( + + + {invite.invite_code} + + + + {status} + + + + {new Date(date).toLocaleDateString()} + + + ); + })} + + + + )} + ); } diff --git a/src/pages/api/invites/create.ts b/src/pages/api/invites/create.ts index 8e09dd2..2db6d4f 100644 --- a/src/pages/api/invites/create.ts +++ b/src/pages/api/invites/create.ts @@ -17,75 +17,98 @@ export const POST: APIRoute = async ({ cookies, request }) => { }); } - // Check for invites created in the last 24 hours - const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + try { + const body = await request.json(); + const { inviteeName, metInPerson, allowContactAccess } = body; - const { data: recentInvites, error: queryError } = await supabase - .from('invite') - .select('created_at') - .eq('inviter_id', user.id) - .gte('created_at', oneDayAgo) - .limit(1); + // Validate required fields + if (!inviteeName || !metInPerson || !allowContactAccess) { + return new Response( + JSON.stringify({ + error: + 'All fields are required: invitee name, met in person confirmation, and contact access confirmation.', + }), + { + status: 400, + } + ); + } - if (queryError) { - return new Response(JSON.stringify({ error: 'Database error' }), { - status: 500, - }); - } + // Check for invites created in the last 24 hours + const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); - if (recentInvites && recentInvites.length > 0) { - return new Response( - JSON.stringify({ - error: 'You can only create one invite code every 24 hours.', - }), - { status: 429 } - ); - } + const { data: recentInvites, error: queryError } = await supabase + .from('invite') + .select('created_at') + .eq('inviter_id', user.id) + .gte('created_at', oneDayAgo) + .limit(1); - // Generate random 8-char code with retries - // Using ambiguous-safe alphanumeric characters (excluding I, L, O, 0, 1) - const chars = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; - const maxRetries = 3; + if (queryError) { + return new Response(JSON.stringify({ error: 'Database error' }), { + status: 500, + }); + } - for (let attempt = 0; attempt < maxRetries; attempt++) { - let code = ''; - for (let i = 0; i < 8; i++) { - code += chars.charAt(Math.floor(Math.random() * chars.length)); + if (recentInvites && recentInvites.length > 0) { + return new Response( + JSON.stringify({ + error: 'You can only create one invite code every 24 hours.', + }), + { status: 429 } + ); } - // Insert new invite - const { data: newInvite, error: insertError } = await supabase - .from('invite') - .insert({ - inviter_id: user.id, - invite_code: code, - }) - .select() - .single(); + // Generate random 8-char code with retries + // Using ambiguous-safe alphanumeric characters (excluding I, L, O, 0, 1) + const chars = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; + const maxRetries = 3; - if (insertError) { - // Handle potential collision - if (insertError.code === '23505') { - // Unique violation, try again - continue; + for (let attempt = 0; attempt < maxRetries; attempt++) { + let code = ''; + for (let i = 0; i < 8; i++) { + code += chars.charAt(Math.floor(Math.random() * chars.length)); } - // Other errors are fatal - return new Response(JSON.stringify({ error: insertError.message }), { - status: 500, + + // Insert new invite + const { data: newInvite, error: insertError } = await supabase + .from('invite') + .insert({ + inviter_id: user.id, + invite_code: code, + invitee_name: inviteeName, + }) + .select() + .single(); + + if (insertError) { + // Handle potential collision + if (insertError.code === '23505') { + // Unique violation, try again + continue; + } + // Other errors are fatal + return new Response(JSON.stringify({ error: insertError.message }), { + status: 500, + }); + } + + // Success + return new Response(JSON.stringify(newInvite), { + status: 201, }); } - // Success - return new Response(JSON.stringify(newInvite), { - status: 201, + // If we exhausted all retries + return new Response( + JSON.stringify({ + error: 'Failed to generate a unique invite code. Please try again.', + }), + { status: 500 } + ); + } catch { + return new Response(JSON.stringify({ error: 'Invalid request' }), { + status: 400, }); } - - // If we exhausted all retries - return new Response( - JSON.stringify({ - error: 'Failed to generate a unique invite code. Please try again.', - }), - { status: 500 } - ); }; diff --git a/src/pages/dashboard.astro b/src/pages/dashboard.astro index 807ba90..28cae62 100644 --- a/src/pages/dashboard.astro +++ b/src/pages/dashboard.astro @@ -2,6 +2,7 @@ import Layout from '../layouts/Layout.astro'; import LogoutButton from '../components/react/LogoutButton'; import PageHeader from '../components/astro/PageHeader.astro'; +import Card from '../components/react/Card'; // User is set by middleware for protected routes const user = Astro.locals.user; @@ -25,46 +26,8 @@ const createdAt = user?.created_at -
-
-

- Account Information -

-
-
-
Email
-
{user?.email || 'Not set'}
-
-
-
User ID
-
- {user?.id || 'Unknown'} -
-
-
-
Account Created
-
{createdAt}
-
-
-
Last Sign In
-
- { - user?.last_sign_in_at - ? new Date(user.last_sign_in_at).toLocaleString('en-US', { - dateStyle: 'medium', - timeStyle: 'short', - }) - : 'Unknown' - } -
-
-
-
- -
-

Quick Actions

+
+ -
+ + + +
+
+
Email
+
{user?.email || 'Not set'}
+
+
+
User ID
+
+ {user?.id || 'Unknown'} +
+
+
+
Account Created
+
{createdAt}
+
+
+
Last Sign In
+
+ { + user?.last_sign_in_at + ? new Date(user.last_sign_in_at).toLocaleString('en-US', { + dateStyle: 'medium', + timeStyle: 'short', + }) + : 'Unknown' + } +
+
+
+
diff --git a/supabase/migrations/20251120120000_add_invitee_name_to_invites.sql b/supabase/migrations/20251120120000_add_invitee_name_to_invites.sql new file mode 100644 index 0000000..7ab0f91 --- /dev/null +++ b/supabase/migrations/20251120120000_add_invitee_name_to_invites.sql @@ -0,0 +1,2 @@ +-- Add invitee_name column to invite table +alter table invite add column invitee_name text not null default ''; From f271a7ddeddf2757977a888af7bc7667268056c6 Mon Sep 17 00:00:00 2001 From: Addison Emig Date: Fri, 21 Nov 2025 05:55:43 -0600 Subject: [PATCH 22/33] feat: add invite name --- src/components/react/InviteManager.tsx | 177 ++++++++++++++++-- src/lib/database.types.ts | 3 + src/pages/api/invites/create.ts | 9 +- ...20251119112252_update_category_id_type.sql | 7 +- ...1120120000_add_invitee_name_to_invites.sql | 2 - .../20251121113622_add_invite_name.sql | 1 + supabase/schemas/08_invites.sql | 1 + supabase/seeds/05_connections_invites.sql | 18 +- 8 files changed, 183 insertions(+), 35 deletions(-) delete mode 100644 supabase/migrations/20251120120000_add_invitee_name_to_invites.sql create mode 100644 supabase/migrations/20251121113622_add_invite_name.sql diff --git a/src/components/react/InviteManager.tsx b/src/components/react/InviteManager.tsx index 9432e1b..ab51b50 100644 --- a/src/components/react/InviteManager.tsx +++ b/src/components/react/InviteManager.tsx @@ -15,6 +15,12 @@ export default function InviteManager({ initialInvites }: InviteManagerProps) { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + // Form state + const [name, setName] = useState(''); + const [metInPerson, setMetInPerson] = useState(false); + const [allowContactAccess, setAllowContactAccess] = useState(false); + const [showForm, setShowForm] = useState(false); + const activeInvites = invites.filter( (invite) => !invite.used_at && !invite.revoked_at ); @@ -32,6 +38,30 @@ export default function InviteManager({ initialInvites }: InviteManagerProps) { const createInvite = async () => { setLoading(true); setError(null); + + // Validate form + if (!name.trim()) { + setError("Please enter the invitee's name"); + setLoading(false); + return; + } + + if (!metInPerson) { + setError( + 'You must confirm that you have met this person in person multiple times' + ); + setLoading(false); + return; + } + + if (!allowContactAccess) { + setError( + 'You must agree to allow this person to have access to your Contacts-Only information' + ); + setLoading(false); + return; + } + try { const supabase = createSupabaseBrowserClient(); const { @@ -45,8 +75,10 @@ export default function InviteManager({ initialInvites }: InviteManagerProps) { const res = await fetch('/api/invites/create', { method: 'POST', headers: { + 'Content-Type': 'application/json', Authorization: `Bearer ${session.access_token}`, }, + body: JSON.stringify({ name: name.trim() }), }); if (!res.ok) { @@ -56,6 +88,12 @@ export default function InviteManager({ initialInvites }: InviteManagerProps) { const newInvite = await res.json(); setInvites((prev) => [newInvite, ...prev]); + + // Reset form + setName(''); + setMetInPerson(false); + setAllowContactAccess(false); + setShowForm(false); } catch (err) { if (err instanceof Error) { setError(err.message); @@ -135,20 +173,120 @@ export default function InviteManager({ initialInvites }: InviteManagerProps) { return (
- -
-
- -
- - {error && ( -
- {error} + +
+ {/* Trust Warning */} +
+
+
+

+ Important Trust Notice +

+

+ Only invite people you trust and have met in person multiple + times. This person will have access to your contact + information and connections. +

+
+
- )} + {!showForm ? ( + + ) : ( +
+
+ + setName(e.target.value)} + className="w-full px-3 py-2 bg-surface-base border border-surface-border rounded-lg text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent" + placeholder="Enter the person's full name" + required + /> +
+ +
+
+ setMetInPerson(e.target.checked)} + className="mt-1 h-4 w-4 text-primary-600 focus:ring-primary-500 border-surface-border rounded bg-surface-base" + required + /> + +
+ +
+ setAllowContactAccess(e.target.checked)} + className="mt-1 h-4 w-4 text-primary-600 focus:ring-primary-500 border-surface-border rounded bg-surface-base" + required + /> + +
+
+ + {error && ( +
+ {error} +
+ )} + +
+ + +
+
+ )} +
+
+ + {activeInvites.length === 0 ? (

No active invite codes.

) : ( @@ -162,7 +300,10 @@ export default function InviteManager({ initialInvites }: InviteManagerProps) {
{invite.invite_code}
-
+
+ For: {invite.name} +
+
Created: {new Date(invite.created_at).toLocaleDateString()}
@@ -205,6 +346,7 @@ export default function InviteManager({ initialInvites }: InviteManagerProps) { Code + Invitee Status Date @@ -230,12 +372,15 @@ export default function InviteManager({ initialInvites }: InviteManagerProps) { {invite.invite_code} + + {invite.name} + {status} diff --git a/src/lib/database.types.ts b/src/lib/database.types.ts index 79ba6e7..ab4741e 100644 --- a/src/lib/database.types.ts +++ b/src/lib/database.types.ts @@ -135,6 +135,7 @@ export type Database = { id: string; invite_code: string; inviter_id: string; + name: string; revoked_at: string | null; used_at: string | null; used_by: string | null; @@ -144,6 +145,7 @@ export type Database = { id?: string; invite_code: string; inviter_id: string; + name?: string; revoked_at?: string | null; used_at?: string | null; used_by?: string | null; @@ -153,6 +155,7 @@ export type Database = { id?: string; invite_code?: string; inviter_id?: string; + name?: string; revoked_at?: string | null; used_at?: string | null; used_by?: string | null; diff --git a/src/pages/api/invites/create.ts b/src/pages/api/invites/create.ts index 2db6d4f..b72527f 100644 --- a/src/pages/api/invites/create.ts +++ b/src/pages/api/invites/create.ts @@ -19,14 +19,13 @@ export const POST: APIRoute = async ({ cookies, request }) => { try { const body = await request.json(); - const { inviteeName, metInPerson, allowContactAccess } = body; + const { name } = body; // Validate required fields - if (!inviteeName || !metInPerson || !allowContactAccess) { + if (!name) { return new Response( JSON.stringify({ - error: - 'All fields are required: invitee name, met in person confirmation, and contact access confirmation.', + error: 'Invite name is required', }), { status: 400, @@ -76,7 +75,7 @@ export const POST: APIRoute = async ({ cookies, request }) => { .insert({ inviter_id: user.id, invite_code: code, - invitee_name: inviteeName, + name: name, }) .select() .single(); diff --git a/supabase/migrations/20251119112252_update_category_id_type.sql b/supabase/migrations/20251119112252_update_category_id_type.sql index 319d5be..e85be67 100644 --- a/supabase/migrations/20251119112252_update_category_id_type.sql +++ b/supabase/migrations/20251119112252_update_category_id_type.sql @@ -1,5 +1,5 @@ -- Drop foreign key constraint first -alter table "public"."item" drop constraint if exists "item_category_id_fkey"; +alter table "public"."item" drop constraint "item_category_id_fkey"; -- Drop primary key constraint (this also drops the underlying index) alter table "public"."category" drop constraint if exists "category_pkey"; @@ -19,5 +19,6 @@ alter table "public"."category" add constraint "category_pkey" primary key ("id" CREATE INDEX idx_item_category_id ON public.item USING btree (category_id); -- Recreate foreign key constraint -alter table "public"."item" add constraint "item_category_id_fkey" - foreign key ("category_id") references "public"."category"("id") on delete set null; +alter table "public"."item" add constraint "item_category_id_fkey" FOREIGN KEY (category_id) REFERENCES public.category(id) not valid; + +alter table "public"."item" validate constraint "item_category_id_fkey"; diff --git a/supabase/migrations/20251120120000_add_invitee_name_to_invites.sql b/supabase/migrations/20251120120000_add_invitee_name_to_invites.sql deleted file mode 100644 index 7ab0f91..0000000 --- a/supabase/migrations/20251120120000_add_invitee_name_to_invites.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Add invitee_name column to invite table -alter table invite add column invitee_name text not null default ''; diff --git a/supabase/migrations/20251121113622_add_invite_name.sql b/supabase/migrations/20251121113622_add_invite_name.sql new file mode 100644 index 0000000..2b0657f --- /dev/null +++ b/supabase/migrations/20251121113622_add_invite_name.sql @@ -0,0 +1 @@ +alter table "public"."invite" add column "name" text not null default ''::text; diff --git a/supabase/schemas/08_invites.sql b/supabase/schemas/08_invites.sql index 9a0de4b..a0492dc 100644 --- a/supabase/schemas/08_invites.sql +++ b/supabase/schemas/08_invites.sql @@ -3,6 +3,7 @@ create table invite ( id uuid primary key default uuid_generate_v4(), inviter_id uuid not null references "user"(id) on delete cascade, + name text not null default '', invite_code text not null unique, used_by uuid references "user"(id), used_at timestamptz, diff --git a/supabase/seeds/05_connections_invites.sql b/supabase/seeds/05_connections_invites.sql index 0106451..b8db641 100644 --- a/supabase/seeds/05_connections_invites.sql +++ b/supabase/seeds/05_connections_invites.sql @@ -27,20 +27,20 @@ insert into connection (id, user_a, user_b, status, created_at) values on conflict (id) do nothing; -- Invite records -insert into invite (id, inviter_id, invite_code, used_by, used_at, created_at) values +insert into invite (id, inviter_id, name, invite_code, used_by, used_at, created_at) values -- Alice's used invites - ('66666666-6666-6666-6666-666666666601', '22222222-2222-2222-2222-222222222201', 'ALICE001', '22222222-2222-2222-2222-222222222202', '2025-10-25 14:30:00+00', '2025-10-24 10:00:00+00'), - ('66666666-6666-6666-6666-666666666602', '22222222-2222-2222-2222-222222222201', 'ALICE002', '22222222-2222-2222-2222-222222222203', '2025-10-30 09:15:00+00', '2025-10-29 08:00:00+00'), + ('66666666-6666-6666-6666-666666666601', '22222222-2222-2222-2222-222222222201', 'Bob', 'ALICE001', '22222222-2222-2222-2222-222222222202', '2025-10-25 14:30:00+00', '2025-10-24 10:00:00+00'), + ('66666666-6666-6666-6666-666666666602', '22222222-2222-2222-2222-222222222201', 'Carol', 'ALICE002', '22222222-2222-2222-2222-222222222203', '2025-10-30 09:15:00+00', '2025-10-29 08:00:00+00'), -- Bob's used invite - ('66666666-6666-6666-6666-666666666603', '22222222-2222-2222-2222-222222222202', 'BOB00001', '22222222-2222-2222-2222-222222222204', '2025-11-04 11:00:00+00', '2025-11-03 15:00:00+00'), + ('66666666-6666-6666-6666-666666666603', '22222222-2222-2222-2222-222222222202', 'David', 'BOB00001', '22222222-2222-2222-2222-222222222204', '2025-11-04 11:00:00+00', '2025-11-03 15:00:00+00'), -- Carol's used invite - ('66666666-6666-6666-6666-666666666604', '22222222-2222-2222-2222-222222222203', 'CAROL001', '22222222-2222-2222-2222-222222222205', '2025-11-09 16:45:00+00', '2025-11-08 12:00:00+00'), + ('66666666-6666-6666-6666-666666666604', '22222222-2222-2222-2222-222222222203', 'Eve', 'CAROL001', '22222222-2222-2222-2222-222222222205', '2025-11-09 16:45:00+00', '2025-11-08 12:00:00+00'), -- Unused invites for testing - ('66666666-6666-6666-6666-666666666605', '22222222-2222-2222-2222-222222222201', 'TESTCODE', null, null, '2025-11-17 10:00:00+00'), - ('66666666-6666-6666-6666-666666666606', '22222222-2222-2222-2222-222222222204', 'DAVID001', null, null, '2025-11-18 09:00:00+00') + ('66666666-6666-6666-6666-666666666605', '22222222-2222-2222-2222-222222222201', 'Test', 'TESTCODE', null, null, '2025-11-17 10:00:00+00'), + ('66666666-6666-6666-6666-666666666606', '22222222-2222-2222-2222-222222222204', 'James', 'DAVID001', null, null, '2025-11-18 09:00:00+00') on conflict (id) do nothing; -- Revoked invite example -insert into invite (id, inviter_id, invite_code, revoked_at, created_at) values - ('66666666-6666-6666-6666-666666666607', '22222222-2222-2222-2222-222222222202', 'REVOKED1', '2025-11-14 10:00:00+00', '2025-11-12 08:00:00+00') +insert into invite (id, inviter_id, name, invite_code, revoked_at, created_at) values + ('66666666-6666-6666-6666-666666666607', '22222222-2222-2222-2222-222222222202', 'Revoked', 'REVOKED1', '2025-11-14 10:00:00+00', '2025-11-12 08:00:00+00') on conflict (id) do nothing; From 5924bbef3f42db44af9b444c3f857490c5015041 Mon Sep 17 00:00:00 2001 From: Addison Emig Date: Fri, 21 Nov 2025 05:58:15 -0600 Subject: [PATCH 23/33] fix: name --- src/components/react/InviteManager.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/components/react/InviteManager.tsx b/src/components/react/InviteManager.tsx index ab51b50..ff0e2f2 100644 --- a/src/components/react/InviteManager.tsx +++ b/src/components/react/InviteManager.tsx @@ -184,8 +184,7 @@ export default function InviteManager({ initialInvites }: InviteManagerProps) {

Only invite people you trust and have met in person multiple - times. This person will have access to your contact - information and connections. + times. This person will automatically be connected to you.

@@ -346,7 +345,7 @@ export default function InviteManager({ initialInvites }: InviteManagerProps) { Code - Invitee + Name Status Date From 1b6cb89f0cfc2a3f05e735a547c0bc4b34412821 Mon Sep 17 00:00:00 2001 From: Addison Emig Date: Fri, 21 Nov 2025 06:01:19 -0600 Subject: [PATCH 24/33] chore: revert auth --- src/lib/auth.ts | 52 +++++++++++++------------------------------------ 1 file changed, 14 insertions(+), 38 deletions(-) diff --git a/src/lib/auth.ts b/src/lib/auth.ts index fee8112..7ea9111 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -14,35 +14,21 @@ const supabaseAnonKey = import.meta.env.PUBLIC_SUPABASE_ANON_KEY; */ export function createSupabaseServerClient( cookies: AstroCookies, - cookieHeader?: string | null, - authHeader?: string | null + cookieHeader?: string | null ) { - const formatCookies = ( - cookieList: { name: string; value: string | undefined }[] - ) => - cookieList - .filter( - (cookie): cookie is { name: string; value: string } => - typeof cookie.value === 'string' - ) - .map((cookie) => ({ name: cookie.name, value: cookie.value })); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const config: any = { + return createServerClient(supabaseUrl, supabaseAnonKey, { cookies: { getAll() { - if (cookieHeader) { - return formatCookies(parseCookieHeader(cookieHeader)); - } - - const astroCookies = cookies - .getAll() - .map(({ name, value }) => ({ name, value })); - - return formatCookies(astroCookies); + const parsed = parseCookieHeader(cookieHeader ?? ''); + // Filter out cookies without values and ensure type safety + return parsed + .filter( + (cookie): cookie is { name: string; value: string } => + cookie.value !== undefined + ) + .map((cookie) => ({ name: cookie.name, value: cookie.value })); }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - setAll(cookiesToSet: any) { + setAll(cookiesToSet) { for (const { name, value, options } of cookiesToSet) { cookies.set(name, value, { path: '/', @@ -54,17 +40,7 @@ export function createSupabaseServerClient( } }, }, - }; - - if (authHeader) { - config.global = { - headers: { - Authorization: authHeader, - }, - }; - } - - return createServerClient(supabaseUrl, supabaseAnonKey, config); + }); } /** @@ -82,7 +58,7 @@ export async function getSession( cookies: AstroCookies, cookieHeader?: string | null ) { - const supabase = createSupabaseServerClient(cookies, cookieHeader, undefined); + const supabase = createSupabaseServerClient(cookies, cookieHeader); const { data: { session }, error, @@ -104,7 +80,7 @@ export async function getUser( cookies: AstroCookies, cookieHeader?: string | null ) { - const supabase = createSupabaseServerClient(cookies, cookieHeader, undefined); + const supabase = createSupabaseServerClient(cookies, cookieHeader); const { data: { user }, error, From 147504ea3c23015c245705cdc3de9fc84429f548 Mon Sep 17 00:00:00 2001 From: Addison Emig Date: Fri, 21 Nov 2025 06:17:38 -0600 Subject: [PATCH 25/33] fix: JWT authentication for invite API endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed "Unauthorized" and RLS policy violation errors when creating or revoking invite codes. The issue was that API endpoints were not properly setting the JWT authentication context for RLS policy evaluation. Changes: - Added createSupabaseWithJWT() helper in auth.ts to create Supabase clients with JWT auth context - Updated /api/invites/create to use JWT-authenticated client for database operations - Updated /api/invites/revoke to use JWT-authenticated client for database operations - Fixed token validation to pass JWT to auth.getUser() Now auth.uid() in RLS policies correctly evaluates to the authenticated user's ID. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/components/react/InviteManager.tsx | 2 +- src/lib/auth.ts | 23 ++++++++++ src/pages/api/invites/create.ts | 61 +++++++++++++++++++++++--- src/pages/api/invites/revoke.ts | 36 ++++++++++++--- 4 files changed, 111 insertions(+), 11 deletions(-) diff --git a/src/components/react/InviteManager.tsx b/src/components/react/InviteManager.tsx index ff0e2f2..2c5bd2c 100644 --- a/src/components/react/InviteManager.tsx +++ b/src/components/react/InviteManager.tsx @@ -379,7 +379,7 @@ export default function InviteManager({ initialInvites }: InviteManagerProps) { className={`inline-flex items-center px-4 py-1 rounded-full text-xs font-bold border ${ status === 'Used' ? 'border-primary-700 text-primary-400' - : 'border-neutral-700 text-neutral-400' + : 'border-neutral-500 text-neutral-400' }`} > {status} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 7ea9111..16c4a96 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -51,6 +51,29 @@ export function createSupabaseBrowserClient() { return createBrowserClient(supabaseUrl, supabaseAnonKey); } +/** + * Create a Supabase client for API routes with JWT token authentication. + * Sets the JWT as the global auth token for RLS policy evaluation. + */ +export function createSupabaseWithJWT(jwt: string) { + const client = createServerClient(supabaseUrl, supabaseAnonKey, { + cookies: { + getAll() { + return []; + }, + setAll() { + // No-op for JWT-based clients + }, + }, + global: { + headers: { + Authorization: `Bearer ${jwt}`, + }, + }, + }); + return client; +} + /** * Get the current session from a server-side Supabase client. */ diff --git a/src/pages/api/invites/create.ts b/src/pages/api/invites/create.ts index b72527f..a1d8a89 100644 --- a/src/pages/api/invites/create.ts +++ b/src/pages/api/invites/create.ts @@ -1,22 +1,71 @@ import type { APIRoute } from 'astro'; -import { createSupabaseServerClient } from '../../../lib/auth'; +import { + createSupabaseServerClient, + createSupabaseWithJWT, +} from '../../../lib/auth'; export const POST: APIRoute = async ({ cookies, request }) => { const authHeader = request.headers.get('Authorization'); - const supabase = createSupabaseServerClient(cookies, undefined, authHeader); - // Get current user + // Extract token from Authorization header + const token = authHeader?.replace('Bearer ', ''); + + if (!token) { + console.error('[API] No token found in Authorization header'); + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + }); + } + + // Create a temporary client to validate the token + const tempSupabase = createSupabaseServerClient(cookies, undefined); + + // Get current user - pass the token directly to getUser() const { data: { user }, error: userError, - } = await supabase.auth.getUser(); + } = await tempSupabase.auth.getUser(token); + + if (userError) { + console.error('[API] Auth error:', userError.message); + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + }); + } - if (userError || !user) { + if (!user) { + console.error('[API] No user found after authentication'); return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, }); } + // Create a new Supabase client with the JWT token for RLS context + const supabase = createSupabaseWithJWT(token); + + // Check if user exists in user table + const { data: dbUser, error: dbUserError } = await supabase + .from('user') + .select('id, display_name') + .eq('id', user.id) + .single(); + + if (dbUserError) { + console.error( + '[API] Error checking user in database:', + dbUserError.message + ); + } + if (!dbUser) { + console.error('[API] User authenticated but not in user table!'); + return new Response( + JSON.stringify({ + error: 'User profile not found. Please complete signup first.', + }), + { status: 403 } + ); + } + try { const body = await request.json(); const { name } = body; @@ -81,6 +130,8 @@ export const POST: APIRoute = async ({ cookies, request }) => { .single(); if (insertError) { + console.error('[API] Insert error:', insertError.message); + // Handle potential collision if (insertError.code === '23505') { // Unique violation, try again diff --git a/src/pages/api/invites/revoke.ts b/src/pages/api/invites/revoke.ts index 9b54ea6..641cb1c 100644 --- a/src/pages/api/invites/revoke.ts +++ b/src/pages/api/invites/revoke.ts @@ -1,22 +1,48 @@ import type { APIRoute } from 'astro'; -import { createSupabaseServerClient } from '../../../lib/auth'; +import { + createSupabaseServerClient, + createSupabaseWithJWT, +} from '../../../lib/auth'; export const POST: APIRoute = async ({ request, cookies }) => { const authHeader = request.headers.get('Authorization'); - const supabase = createSupabaseServerClient(cookies, undefined, authHeader); - // Get current user + // Extract token from Authorization header + const token = authHeader?.replace('Bearer ', ''); + + if (!token) { + console.error('[API] No token found in Authorization header'); + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + }); + } + + // Create a temporary client to validate the token + const tempSupabase = createSupabaseServerClient(cookies, undefined); + + // Get current user - pass the token directly to getUser() const { data: { user }, error: userError, - } = await supabase.auth.getUser(); + } = await tempSupabase.auth.getUser(token); - if (userError || !user) { + if (userError) { + console.error('[API] Auth error:', userError.message); return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, }); } + if (!user) { + console.error('[API] No user found after authentication'); + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + }); + } + + // Create a new Supabase client with the JWT token for RLS context + const supabase = createSupabaseWithJWT(token); + try { const body = await request.json(); const { inviteId } = body; From c7ec9577c48db9ec7eb19d39ae68cca9a7ba6a02 Mon Sep 17 00:00:00 2001 From: Addison Emig Date: Fri, 21 Nov 2025 06:22:03 -0600 Subject: [PATCH 26/33] refactor: simplify --- src/pages/api/invites/create.ts | 20 +++++++------------- src/pages/api/invites/revoke.ts | 18 ++++++------------ 2 files changed, 13 insertions(+), 25 deletions(-) diff --git a/src/pages/api/invites/create.ts b/src/pages/api/invites/create.ts index a1d8a89..8ebb480 100644 --- a/src/pages/api/invites/create.ts +++ b/src/pages/api/invites/create.ts @@ -1,10 +1,7 @@ import type { APIRoute } from 'astro'; -import { - createSupabaseServerClient, - createSupabaseWithJWT, -} from '../../../lib/auth'; +import { createSupabaseWithJWT } from '../../../lib/auth'; -export const POST: APIRoute = async ({ cookies, request }) => { +export const POST: APIRoute = async ({ request }) => { const authHeader = request.headers.get('Authorization'); // Extract token from Authorization header @@ -17,14 +14,14 @@ export const POST: APIRoute = async ({ cookies, request }) => { }); } - // Create a temporary client to validate the token - const tempSupabase = createSupabaseServerClient(cookies, undefined); + // Create a Supabase client with the JWT token for authentication and RLS context + const supabase = createSupabaseWithJWT(token); - // Get current user - pass the token directly to getUser() + // Validate the token and get current user const { data: { user }, error: userError, - } = await tempSupabase.auth.getUser(token); + } = await supabase.auth.getUser(token); if (userError) { console.error('[API] Auth error:', userError.message); @@ -40,9 +37,6 @@ export const POST: APIRoute = async ({ cookies, request }) => { }); } - // Create a new Supabase client with the JWT token for RLS context - const supabase = createSupabaseWithJWT(token); - // Check if user exists in user table const { data: dbUser, error: dbUserError } = await supabase .from('user') @@ -101,7 +95,7 @@ export const POST: APIRoute = async ({ cookies, request }) => { if (recentInvites && recentInvites.length > 0) { return new Response( JSON.stringify({ - error: 'You can only create one invite code every 24 hours.', + error: 'You can only generate one invite code every 24 hours.', }), { status: 429 } ); diff --git a/src/pages/api/invites/revoke.ts b/src/pages/api/invites/revoke.ts index 641cb1c..55fa593 100644 --- a/src/pages/api/invites/revoke.ts +++ b/src/pages/api/invites/revoke.ts @@ -1,10 +1,7 @@ import type { APIRoute } from 'astro'; -import { - createSupabaseServerClient, - createSupabaseWithJWT, -} from '../../../lib/auth'; +import { createSupabaseWithJWT } from '../../../lib/auth'; -export const POST: APIRoute = async ({ request, cookies }) => { +export const POST: APIRoute = async ({ request }) => { const authHeader = request.headers.get('Authorization'); // Extract token from Authorization header @@ -17,14 +14,14 @@ export const POST: APIRoute = async ({ request, cookies }) => { }); } - // Create a temporary client to validate the token - const tempSupabase = createSupabaseServerClient(cookies, undefined); + // Create a Supabase client with the JWT token for authentication and RLS context + const supabase = createSupabaseWithJWT(token); - // Get current user - pass the token directly to getUser() + // Validate the token and get current user const { data: { user }, error: userError, - } = await tempSupabase.auth.getUser(token); + } = await supabase.auth.getUser(token); if (userError) { console.error('[API] Auth error:', userError.message); @@ -40,9 +37,6 @@ export const POST: APIRoute = async ({ request, cookies }) => { }); } - // Create a new Supabase client with the JWT token for RLS context - const supabase = createSupabaseWithJWT(token); - try { const body = await request.json(); const { inviteId } = body; From 5124978c07928b677da938c73f3a2f070f26af01 Mon Sep 17 00:00:00 2001 From: Addison Emig Date: Fri, 21 Nov 2025 06:24:26 -0600 Subject: [PATCH 27/33] fix: type error --- src/pages/invites.astro | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/pages/invites.astro b/src/pages/invites.astro index 5fb69e0..c5e25af 100644 --- a/src/pages/invites.astro +++ b/src/pages/invites.astro @@ -13,11 +13,7 @@ if (!user) { const cookieHeader = Astro.request.headers.get('cookie'); -const supabase = createSupabaseServerClient( - Astro.cookies, - cookieHeader, - undefined -); +const supabase = createSupabaseServerClient(Astro.cookies, cookieHeader); // Fetch user's invites const { data: invites, error } = await supabase From cbb6c42ac3de4af79f5469a539f42703a38777ff Mon Sep 17 00:00:00 2001 From: Addison Emig Date: Fri, 21 Nov 2025 06:33:22 -0600 Subject: [PATCH 28/33] feat: improve share text --- src/components/react/InviteManager.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/react/InviteManager.tsx b/src/components/react/InviteManager.tsx index 2c5bd2c..604eab9 100644 --- a/src/components/react/InviteManager.tsx +++ b/src/components/react/InviteManager.tsx @@ -3,6 +3,7 @@ import Button from './Button'; import Card from './Card'; import type { Tables } from '../../lib/database.types'; import { createSupabaseBrowserClient } from '../../lib/auth'; +import { platformName } from '../../lib/globals'; type Invite = Tables<'invite'>; @@ -159,9 +160,9 @@ export default function InviteManager({ initialInvites }: InviteManagerProps) { if (navigator.share) { try { await navigator.share({ - title: 'Join Market', - text: `Join me on Market! Use my invite code: ${code}`, - url: window.location.origin + '/auth/signup?code=' + code, + title: `Join ${platformName}`, + text: `Join me on ${platformName}! Use my invite code: ${code}`, + url: window.location.origin + '/auth/login', }); } catch (err) { console.error('Error sharing:', err); From e735f312255e6be8f47b150426048277d5fbb49c Mon Sep 17 00:00:00 2001 From: Addison Emig Date: Fri, 21 Nov 2025 06:34:35 -0600 Subject: [PATCH 29/33] fix: do not include revoked invites --- src/pages/api/invites/create.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/api/invites/create.ts b/src/pages/api/invites/create.ts index 8ebb480..3ee535c 100644 --- a/src/pages/api/invites/create.ts +++ b/src/pages/api/invites/create.ts @@ -76,13 +76,14 @@ export const POST: APIRoute = async ({ request }) => { ); } - // Check for invites created in the last 24 hours + // Check for non-revoked invites created in the last 24 hours const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); const { data: recentInvites, error: queryError } = await supabase .from('invite') .select('created_at') .eq('inviter_id', user.id) + .eq('revoked_at', null) .gte('created_at', oneDayAgo) .limit(1); From b23bab3da52f4c528549c08c4a763c1f944add95 Mon Sep 17 00:00:00 2001 From: Addison Emig Date: Fri, 21 Nov 2025 06:39:27 -0600 Subject: [PATCH 30/33] feat: add SQL function to check creating invites --- src/pages/api/invites/create.ts | 22 ++++++++----------- ...1123637_add_can_create_invite_function.sql | 16 ++++++++++++++ supabase/schemas/08_invites.sql | 17 ++++++++++++++ 3 files changed, 42 insertions(+), 13 deletions(-) create mode 100644 supabase/migrations/20251121123637_add_can_create_invite_function.sql diff --git a/src/pages/api/invites/create.ts b/src/pages/api/invites/create.ts index 3ee535c..acc00da 100644 --- a/src/pages/api/invites/create.ts +++ b/src/pages/api/invites/create.ts @@ -76,24 +76,20 @@ export const POST: APIRoute = async ({ request }) => { ); } - // Check for non-revoked invites created in the last 24 hours - const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); - - const { data: recentInvites, error: queryError } = await supabase - .from('invite') - .select('created_at') - .eq('inviter_id', user.id) - .eq('revoked_at', null) - .gte('created_at', oneDayAgo) - .limit(1); - - if (queryError) { + // Check if user can create an invite (uses database time for accuracy) + const { data: canCreate, error: rateLimitError } = await supabase.rpc( + 'can_create_invite', + { user_id: user.id } + ); + + if (rateLimitError) { + console.error('[API] Rate limit check error:', rateLimitError.message); return new Response(JSON.stringify({ error: 'Database error' }), { status: 500, }); } - if (recentInvites && recentInvites.length > 0) { + if (!canCreate) { return new Response( JSON.stringify({ error: 'You can only generate one invite code every 24 hours.', diff --git a/supabase/migrations/20251121123637_add_can_create_invite_function.sql b/supabase/migrations/20251121123637_add_can_create_invite_function.sql new file mode 100644 index 0000000..baf338b --- /dev/null +++ b/supabase/migrations/20251121123637_add_can_create_invite_function.sql @@ -0,0 +1,16 @@ +set check_function_bodies = off; + +CREATE OR REPLACE FUNCTION public.can_create_invite(user_id uuid) + RETURNS boolean + LANGUAGE sql + STABLE SECURITY DEFINER +AS $function$ + select not exists ( + select 1 + from invite + where inviter_id = user_id + and revoked_at is null + and created_at > now() - interval '24 hours' + ); +$function$ +; diff --git a/supabase/schemas/08_invites.sql b/supabase/schemas/08_invites.sql index a0492dc..e4296b9 100644 --- a/supabase/schemas/08_invites.sql +++ b/supabase/schemas/08_invites.sql @@ -42,3 +42,20 @@ create policy "Users can update invites" inviter_id = (select auth.uid()) or used_by = (select auth.uid()) ); + +-- Function to check if user has created a non-revoked invite in the last 24 hours +-- Returns true if user CAN create a new invite, false if rate limited +create or replace function can_create_invite(user_id uuid) +returns boolean +language sql +security definer +stable +as $$ + select not exists ( + select 1 + from invite + where inviter_id = user_id + and revoked_at is null + and created_at > now() - interval '24 hours' + ); +$$; From 0bac015b7d5215c878d50b666604c0c7abb49f1e Mon Sep 17 00:00:00 2001 From: Addison Emig Date: Fri, 21 Nov 2025 06:44:15 -0600 Subject: [PATCH 31/33] feat: move storage policies to declarative schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moved storage policies from migration-only to declarative schema file to prevent future db diff commands from trying to drop them. Changes: - Created supabase/schemas/11_storage.sql with all storage bucket and RLS policies - Storage policies now part of declarative schema source of truth - Prevents unwanted policy drops when running supabase db diff This follows the project's pattern of defining infrastructure in declarative schemas rather than migrations. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- supabase/schemas/11_storage.sql | 208 ++++++++++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 supabase/schemas/11_storage.sql diff --git a/supabase/schemas/11_storage.sql b/supabase/schemas/11_storage.sql new file mode 100644 index 0000000..1c5789c --- /dev/null +++ b/supabase/schemas/11_storage.sql @@ -0,0 +1,208 @@ +-- Storage bucket and RLS policies +-- This schema defines the 'images' storage bucket and access control policies +-- for avatars, item images, and message images + +-- Create the storage bucket if it doesn't exist +insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types) +values ( + 'images', + 'images', + false, + 5242880, -- 5MiB in bytes + array['image/jpeg', 'image/png', 'image/webp'] +) +on conflict (id) do nothing; + +-- ============================================================================= +-- AVATAR POLICIES +-- Path pattern: avatars/{user_id}/... +-- ============================================================================= + +-- Anyone can view avatars (needed for public vendor profiles and authenticated user browsing) +create policy "Anyone can view avatars" +on storage.objects for select +using ( + bucket_id = 'images' and + (storage.foldername(name))[1] = 'avatars' +); + +-- Users can upload their own avatar +create policy "Users can upload own avatar" +on storage.objects for insert +to authenticated +with check ( + bucket_id = 'images' and + (storage.foldername(name))[1] = 'avatars' and + (auth.uid())::text = (storage.foldername(name))[2] +); + +-- Users can update their own avatar +create policy "Users can update own avatar" +on storage.objects for update +to authenticated +using ( + bucket_id = 'images' and + (storage.foldername(name))[1] = 'avatars' and + (auth.uid())::text = (storage.foldername(name))[2] +) +with check ( + bucket_id = 'images' and + (storage.foldername(name))[1] = 'avatars' and + (auth.uid())::text = (storage.foldername(name))[2] +); + +-- Users can delete their own avatar +create policy "Users can delete own avatar" +on storage.objects for delete +to authenticated +using ( + bucket_id = 'images' and + (storage.foldername(name))[1] = 'avatars' and + (auth.uid())::text = (storage.foldername(name))[2] +); + +-- ============================================================================= +-- ITEM IMAGE POLICIES +-- Path pattern: items/{item_id}/... +-- Visibility follows the item's visibility setting +-- ============================================================================= + +-- Users can view item images based on item visibility +create policy "Users can view item images" +on storage.objects for select +to authenticated +using ( + bucket_id = 'images' and + (storage.foldername(name))[1] = 'items' and + exists ( + select 1 from public.item + where item.id::text = (storage.foldername(name))[2] + and ( + -- Owner can always view + item.user_id = auth.uid() + -- Public items (not deleted) + or (item.visibility = 'public' and item.status != 'deleted') + -- Connections-only items (not deleted) for connected users + or ( + item.visibility = 'connections-only' + and item.status != 'deleted' + and exists ( + select 1 from public.connection + where connection.status = 'accepted' + and ( + (connection.user_a = auth.uid() and connection.user_b = item.user_id) + or (connection.user_b = auth.uid() and connection.user_a = item.user_id) + ) + ) + ) + ) + ) +); + +-- Users can upload images for their own items +create policy "Users can upload item images" +on storage.objects for insert +to authenticated +with check ( + bucket_id = 'images' and + (storage.foldername(name))[1] = 'items' and + exists ( + select 1 from public.item + where item.id::text = (storage.foldername(name))[2] + and item.user_id = auth.uid() + ) +); + +-- Users can update images for their own items +create policy "Users can update item images" +on storage.objects for update +to authenticated +using ( + bucket_id = 'images' and + (storage.foldername(name))[1] = 'items' and + exists ( + select 1 from public.item + where item.id::text = (storage.foldername(name))[2] + and item.user_id = auth.uid() + ) +) +with check ( + bucket_id = 'images' and + (storage.foldername(name))[1] = 'items' and + exists ( + select 1 from public.item + where item.id::text = (storage.foldername(name))[2] + and item.user_id = auth.uid() + ) +); + +-- Users can delete images for their own items +create policy "Users can delete item images" +on storage.objects for delete +to authenticated +using ( + bucket_id = 'images' and + (storage.foldername(name))[1] = 'items' and + exists ( + select 1 from public.item + where item.id::text = (storage.foldername(name))[2] + and item.user_id = auth.uid() + ) +); + +-- ============================================================================= +-- MESSAGE IMAGE POLICIES +-- Path pattern: messages/{message_id}/... +-- Only thread participants can view, only sender can upload/delete +-- ============================================================================= + +-- Thread participants can view message images +create policy "Thread participants can view message images" +on storage.objects for select +to authenticated +using ( + bucket_id = 'images' and + (storage.foldername(name))[1] = 'messages' and + exists ( + select 1 from public.message + join public.thread on thread.id = message.thread_id + where message.id::text = (storage.foldername(name))[2] + and ( + thread.creator_id = auth.uid() + or thread.responder_id = auth.uid() + ) + ) +); + +-- Message senders can upload images (must be thread participant) +create policy "Message senders can upload images" +on storage.objects for insert +to authenticated +with check ( + bucket_id = 'images' and + (storage.foldername(name))[1] = 'messages' and + exists ( + select 1 from public.message + join public.thread on thread.id = message.thread_id + where message.id::text = (storage.foldername(name))[2] + and message.sender_id = auth.uid() + and ( + thread.creator_id = auth.uid() + or thread.responder_id = auth.uid() + ) + ) +); + +-- Message senders can delete their own images +create policy "Message senders can delete images" +on storage.objects for delete +to authenticated +using ( + bucket_id = 'images' and + (storage.foldername(name))[1] = 'messages' and + exists ( + select 1 from public.message + where message.id::text = (storage.foldername(name))[2] + and message.sender_id = auth.uid() + ) +); From 1c7bc905e27e0ae93ad4782a8a29058f71422551 Mon Sep 17 00:00:00 2001 From: Addison Emig Date: Fri, 21 Nov 2025 06:50:03 -0600 Subject: [PATCH 32/33] docs: specs improvements --- specs/000-mvp.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/specs/000-mvp.md b/specs/000-mvp.md index e16ef6a..e1988fc 100644 --- a/specs/000-mvp.md +++ b/specs/000-mvp.md @@ -132,6 +132,12 @@ This document contains the basic roadmap for the minimal viable product (MVP). - [ ] **Build signup flow** - [ ] To avoid leaking details about existing users, we will create new user for each new OTP sign up - [ ] Add column to users table that tracks their auth user ID (not nullable) + - [ ] Add invite code entry page for users that have signed in but don't have a record in the users table yet + - [ ] Prompt them to enter invite code + - [ ] Do not allow them to view any other page in the frontend until their enter invite code + - [ ] Update login page with appropriate warning + - [ ] Use "Welcome" instead of "Welcome back" + - [ ] Remove message about needing an invite code to sign up - [ ] We only create new record in the users table once a user has entered a valid invite code - [ ] We will need to remove current trigger that automatically adds records to the users table - [ ] Users that are not in the users table should not be allowed to create new items, threads, or connections (enforced by RLS) From 5e9bedf6907a0d76cb0f4095ae4cedc39b8b7b78 Mon Sep 17 00:00:00 2001 From: Addison Emig Date: Fri, 21 Nov 2025 07:07:51 -0600 Subject: [PATCH 33/33] docs: update architecture --- ARCHITECTURE.md | 266 ++++++++++-------------------------------------- 1 file changed, 56 insertions(+), 210 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3cd7f7f..51560fd 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -10,9 +10,9 @@ A trust-based, invite-only marketplace built with Astro, React, and Supabase. Us All infrastructure defined as code: -- **Supabase**: Database schema via SQL migrations, RLS policies in migrations +- **Supabase**: Database schema via declarative SQL files in `supabase/schemas/`, migrations auto-generated via `supabase db diff` - **Cloudflare**: Workers config in `wrangler.toml`, environment variables in `.dev.vars` -- **Benefits**: Version controlled, reproducible, reviewable, no manual dashboard configuration +- **Benefits**: Version controlled, reproducible, reviewable, no manual dashboard configuration, single source of truth ### 2. Local Development Parity @@ -55,9 +55,9 @@ Every feature should support both power users and those who rank low on the "tec - **Database**: Supabase (PostgreSQL) - **Authentication**: Supabase Auth (email/phone OTP via Twilio) -- **Storage**: Supabase Storage (single bucket) +- **Storage**: Supabase Storage (images bucket with structured folders: avatars/, items/, messages/) - **Real-time**: Supabase Realtime -- **API**: Supabase REST + PostgREST with RLS +- **API**: Supabase REST + PostgREST with RLS, Custom API routes with JWT authentication ### Deployment @@ -83,173 +83,7 @@ Every feature should support both power users and those who rank low on the "tec ## Database Schema -### user - -- id (uuid, pk) -- display_name (text) -- about (text) -- avatar_url (text) -- vendor_id (text, unique, nullable) -- alphanumeric + underscore/dash -- created_at (timestamp) -- invited_by (uuid, fk -> user.id) - -### contact_info - -- id (uuid, pk) -- user_id (uuid, fk -> user.id) -- contact_type (enum: email|phone) -- value (text) -- visibility (enum: hidden|connections-only|public) -- created_at (timestamp) - -### user_settings - -- id (uuid, pk) -- user_id (uuid, fk -> user.id) -- setting_key (text) -- setting_value (jsonb) -- created_at (timestamp) -- updated_at (timestamp) - -### category - -- id (uuid, pk) -- name (text) -- new, resale, service -- description (text) -- created_at (timestamp) - -### item - -- id (uuid, pk) -- user_id (uuid, fk -> user.id) -- type (enum: buy|sell) -- category_id (uuid, fk -> category.id) -- title (text) -- description (text) -- price_string (text) -- price or budget -- visibility (enum: hidden|connections-only|public) -- status (enum: active|archived|deleted) -- created_at (timestamp) -- updated_at (timestamp) - -### item_image - -- id (uuid, pk) -- item_id (uuid, fk -> item.id) -- url (text) -- alt_text (text) -- order_index (integer) -- created_at (timestamp) - -### watch - -- id (uuid, pk) -- name (text) -- query_params (text) -- notify (uuid, fk -> contact_info.id, nullable) - -### connection - -- id (uuid, pk) -- user_a (uuid, fk -> user.id) -- requester -- user_b (uuid, fk -> user.id) -- recipient -- status (enum: pending|accepted|declined) -- created_at (timestamp) -- unique(user_a, user_b) - -### thread - -- id (uuid, pk) -- item_id (uuid, fk -> item.id) -- creator_id (uuid, fk -> user.id) -- thread initiator -- responder_id (uuid, fk -> user.id) -- other participant -- created_at (timestamp) -- unique(item_id, creator_id, responder_id) - -### message - -- id (uuid, pk) -- thread_id (uuid, fk -> thread.id) -- sender_id (uuid, fk -> user.id) -- content (text) -- read (boolean) -- created_at (timestamp) - -### message_image - -- id (uuid, pk) -- message_id (uuid, fk -> message.id) -- url (text) -- order_index (integer) -- created_at (timestamp) - -### invite - -- id (uuid, pk) -- inviter_id (uuid, fk -> user.id) -- invite_code (text, unique) -- 8 alphanumeric characters -- used_by (uuid, fk -> user.id, nullable) -- used_at (timestamp, nullable) -- revoked_at (timestamp, nullable) -- created_at (timestamp) - -## Row Level Security (RLS) - -### user - -- Public profiles: All authenticated users -- Vendor profiles: Accessible via public routes (does not require authentication) - -### contact_info - -- Hidden: System only -- Connections Only: Direct connections only (status='accepted') -- Public: Anyone can view, even if not authenticated - -### user_settings - -- Read/Write: Owner only (user_id) - -### category - -- Public: All authenticated users (read-only) - -### item - -- Hidden: Creator only -- Connections Only: Creator + direct connections (status='accepted') -- Public: All authenticated users -- Buy items: Creator shown as "Anonymous" to non-connections - -### item_image - -- Follows parent item visibility rules -- Images inherit visibility from their item - -### connection - -- Read: Both parties (user_a or user_b) -- Write: user_a creates with status='pending', user_b updates status - -### thread - -- Read/write: Participants only (creator_id or responder_id) -- Thread creator identity follows item visibility rules - -### message - -- Read/write: Participants only (sender_id or recipient in thread) -- Message images inherit thread visibility - -### message_image - -- Follows parent message visibility rules -- Images inherit visibility from their message - -### invite - -- Read/Write: Inviter only (inviter_id) -- Read: Used by user (used_by) for validation +See [here](supabase/schemas) ## Key Flows @@ -296,11 +130,15 @@ Every feature should support both power users and those who rank low on the "tec ### Invite Generation 1. User clicks "Invite someone" -1. System checks last invite timestamp -1. If < 24 hours: shows limit message -1. If eligible: generates 8-character code -1. Creates invite record -1. User can revoke anytime (sets revoked_at) +1. User enters invitee's full name +1. User confirms two requirements: + - "I have met this person in person multiple times and know them well" + - "I agree to allow this person to have access to my Contacts-Only information" +1. System checks rate limit via `can_create_invite()` database function (uses database time, excludes revoked invites) +1. If non-revoked invite exists within last 24 hours: shows limit message +1. If eligible: generates 8-character code (uppercase alphanumeric, excludes I/L/O/0/1) +1. Creates invite record with invitee name +1. User can copy/share code or revoke anytime (sets revoked_at) ## Site Structure @@ -343,54 +181,60 @@ Content: ## Project Structure +(Only important directories and files are shown for brevity) + ``` project-root/ -├── wrangler.toml # Cloudflare Workers config +├── wrangler.jsonc # Cloudflare Workers config ├── supabase/ │ ├── config.toml # Supabase configuration -│ ├── migrations/ -│ │ ├── 001_initial_schema.sql -│ │ ├── 002_rls_policies.sql -│ │ └── 003_indexes.sql -│ └── seed.sql # Test data +│ ├── schemas/ # Declarative database schemas (source of truth) +│ ├── migrations/ # Auto-generated from schemas via `supabase db diff` +│ │ └── *.sql +│ ├── seeds/ # Test data for local development +│ └── storage/ # Seed storage files +│ └── images/ +│ ├── avatars/ +│ ├── items/ +│ └── messages/ ├── src/ │ ├── pages/ # Astro routes +│ │ ├── api/ # API endpoints (JWT-authenticated) +│ │ │ └── invites/ +│ │ ├── auth/ │ │ ├── index.astro # Landing page -│ │ ├── about.astro # About page +│ │ ├── about.mdx # About page (MDX) +│ │ ├── content-policy.mdx # Content policy (MDX) │ │ ├── dashboard.astro # User dashboard -│ │ ├── items/ -│ │ │ ├── index.astro # Item listings -│ │ │ ├── [id].astro # Item details -│ │ │ └── new.astro # Create item -│ │ ├── vendors.astro # Vendor directory -│ │ ├── profile/[id].astro # User profiles -│ │ ├── messages/ -│ │ │ └── index.astro # Message threads -│ │ ├── signup.astro # Invite signup -│ │ ├── [vendor_id].astro # Vendor profile -│ │ └── v/[vendor_id].astro # Alt vendor route +│ │ └── invites.astro # Invite management │ ├── components/ │ │ ├── react/ # Interactive components -│ │ │ ├── ItemForm.tsx -│ │ │ ├── MessageThread.tsx -│ │ │ ├── ItemFeed.tsx -│ │ │ └── ConnectionsList.tsx │ │ └── astro/ # Static components -│ │ ├── Header.astro -│ │ └── ItemCard.astro │ ├── layouts/ -│ │ ├── BaseLayout.astro # Common wrapper -│ │ └── AuthLayout.astro # Auth wrapper -│ └── lib/ -│ ├── supabase.ts # Database client -│ ├── auth.ts # Auth utilities -│ └── utils/ # Helpers +│ │ ├── Layout.astro # Base layout +│ │ ├── PageLayoutWithBreadcrumbs.astro +│ │ └── ProseLayout.astro # MDX layout +│ ├── lib/ +│ │ ├── auth.ts # Auth utilities (createSupabaseWithJWT, etc.) +│ │ ├── database.types.ts # Generated TypeScript types +│ │ ├── globals.ts +│ │ ├── storage.ts +│ │ ├── themeManager.ts +│ │ └── themes.ts +│ ├── styles/ +│ │ ├── global.css +│ │ └── themes.css +│ └── middleware.ts # Route protection ├── tests/ -│ ├── unit/ # Unit tests -│ └── e2e/ # Integration tests +│ ├── unit/ # Unit tests (Vitest) +│ ├── e2e/ # E2E tests (Playwright) +│ └── setup.ts └── .github/ └── workflows/ - └── ci.yml # CI/CD pipeline + ├── ci.yml # CI/CD pipeline + ├── code-review.yml + ├── opencode.yml + └── preview-deploy.yaml ``` ## Security @@ -401,6 +245,7 @@ project-root/ - Session management via Supabase Auth - Protected routes via Astro middleware - Invite-only prevents open signups +- API routes use JWT bearer tokens with `createSupabaseWithJWT()` for RLS context ### Authorization @@ -431,7 +276,8 @@ project-root/ - Auto-compression on upload (balanced quality/size) - 5 images per item/message -- Single storage bucket (simpler for MVP) +- Single 'images' bucket with structured folders (avatars/, items/, messages/) +- Storage RLS policies enforce visibility rules matching parent entities ## Error Handling