Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 5 additions & 20 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,12 @@
# Copy this file to .env.local and fill in the values

# ===========================================
# CLERK AUTHENTICATION
# SUPABASE
# ===========================================
# Get from: https://dashboard.clerk.com
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/login
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/signup
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/roster
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/roster

# ===========================================
# NEON DATABASE
# ===========================================
# Get from: https://neondb.tech
DATABASE_URL=postgresql://user:password@host/database?sslmode=require
# Get from: https://supabase.com/dashboard
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...
SUPABASE_SERVICE_ROLE_KEY=eyJ... # Server only — never expose to client

# ===========================================
# STRIPE CONFIGURATION
Expand All @@ -43,9 +34,3 @@ STRIPE_PRICE_ID=price_...

# Your production site URL
NEXT_PUBLIC_SITE_URL=https://roster.crewcircle.co

# ===========================================
# VERTCEL (auto-injected, do not edit)
# ===========================================
VERCEL_OIDC_TOKEN=
VERCEL=
16 changes: 15 additions & 1 deletion apps/mobile/app/(tabs)/availability.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,20 @@ export default function AvailabilityScreen() {
};

const toggleAvailability = async (dayIndex: number) => {
if (!user) return;

// Look up tenant_id from user's profile
const { data: profileData } = await supabase
.from('profiles')
.select('tenant_id')
.eq('id', user.id)
.single();

if (!profileData?.tenant_id) {
console.error('Could not determine tenant_id for availability insert');
return;
}

const existing = availabilities.find(a => a.day_of_week === dayIndex);

if (existing) {
Expand All @@ -58,7 +72,7 @@ export default function AvailabilityScreen() {

const { data, error } = await supabase
.from('availability')
.insert({ ...newAvailability, profile_id: user?.id })
.insert({ ...newAvailability, profile_id: user.id, tenant_id: profileData.tenant_id })
.select();

if (!error && data) {
Expand Down
26 changes: 22 additions & 4 deletions apps/mobile/context/AuthContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,25 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {

const registerPushToken = async (token: string) => {
try {
if (!session?.user?.id) return;

// Look up tenant_id from profile
const { data: profile } = await supabase
.from('profiles')
.select('tenant_id')
.eq('id', session.user.id)
.single();

if (!profile?.tenant_id) {
console.error('Could not find tenant_id for push token registration');
return;
}

const { data: existingToken, error: fetchError } = await supabase
.from('push_tokens')
.select('id')
.eq('profile_id', session?.user?.id)
.eq('profile_id', session.user.id)
.is('deleted_at', null)
.single();

if (fetchError && fetchError.code !== 'PGRST116') {
Expand All @@ -94,6 +109,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
.update({
expo_push_token: token,
updated_at: new Date().toISOString(),
deleted_at: null,
})
.eq('id', existingToken.id);

Expand All @@ -104,7 +120,8 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
const { error: insertError } = await supabase
.from('push_tokens')
.insert({
profile_id: session?.user?.id,
profile_id: session.user.id,
tenant_id: profile.tenant_id,
expo_push_token: token,
platform,
});
Expand All @@ -123,8 +140,9 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
if (session?.user?.id) {
const { error } = await supabase
.from('push_tokens')
.delete()
.eq('profile_id', session?.user?.id);
.update({ deleted_at: new Date().toISOString() })
.eq('profile_id', session.user.id)
.is('deleted_at', null);

if (error) {
console.error('Error removing push token:', error);
Expand Down
3 changes: 0 additions & 3 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,14 @@
},
"dependencies": {
"@packages/validators": "workspace:*",
"@clerk/nextjs": "^7.5.2",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@neondatabase/serverless": "^1.1.0",
"@supabase/ssr": "^0.12.0",
"@supabase/supabase-js": "^2.108.1",
"@tanstack/react-virtual": "^3.14.2",
"date-fns": "^4.4.0",
"immer": "^11.1.8",
"next": "16.2.9",
"postgres": "^3.4.9",
"react": "19.2.7",
"react-dom": "19.2.7",
"stripe": "^22.2.0",
Expand Down
13 changes: 6 additions & 7 deletions apps/web/src/api/__tests__/rosterApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,17 @@ describe('rosterApi', () => {
});

describe('fetchCurrentRoster', () => {
it('calls GET /api/roster with tenantId and weekStart', async () => {
it('calls GET /api/roster with weekStart', async () => {
const mockResponse = { roster: { id: 'r1' }, shifts: [] };
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse),
});

const result = await rosterApi.fetchCurrentRoster('tenant-1', '2026-01-01');
const result = await rosterApi.fetchCurrentRoster('2026-01-01');

expect(global.fetch).toHaveBeenCalledWith(
'/api/roster?tenantId=tenant-1&weekStart=2026-01-01'
'/api/roster?weekStart=2026-01-01'
);
expect(result).toEqual(mockResponse);
});
Expand All @@ -28,7 +28,7 @@ describe('rosterApi', () => {
json: () => Promise.resolve({ error: 'Not found' }),
});

await expect(rosterApi.fetchCurrentRoster('tenant-1', '2026-01-01'))
await expect(rosterApi.fetchCurrentRoster('2026-01-01'))
.rejects.toThrow('Not found');
});
});
Expand Down Expand Up @@ -94,14 +94,13 @@ describe('rosterApi', () => {
json: () => Promise.resolve(mockResponse),
});

const result = await rosterApi.copyForwardRoster('tenant-1', '2026-01-01', 'roster-1');
const result = await rosterApi.copyForwardRoster('2026-01-01', 'roster-1');

expect(global.fetch).toHaveBeenCalledWith('/api/roster', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'copy-forward',
tenantId: 'tenant-1',
weekStart: '2026-01-01',
rosterId: 'roster-1',
}),
Expand Down Expand Up @@ -198,7 +197,7 @@ describe('rosterApi', () => {
it('throws on network error', async () => {
global.fetch = vi.fn().mockRejectedValue(new Error('Network error'));

await expect(rosterApi.fetchCurrentRoster('tenant-1', '2026-01-01'))
await expect(rosterApi.fetchCurrentRoster('2026-01-01'))
.rejects.toThrow('Network error');
});

Expand Down
5 changes: 1 addition & 4 deletions apps/web/src/api/rosterApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,24 +58,21 @@ export async function unpublishRoster(rosterId: string): Promise<PublishRosterRe
}

export async function copyForwardRoster(
tenantId: string,
weekStart: string,
rosterId: string
): Promise<CopyForwardResult> {
return apiPost<CopyForwardResult>('/api/roster', {
action: 'copy-forward',
tenantId,
weekStart,
rosterId,
});
}

export async function fetchCurrentRoster(
tenantId: string,
weekStart: string
): Promise<FetchRosterResult> {
return apiGet<FetchRosterResult>(
`/api/roster?tenantId=${encodeURIComponent(tenantId)}&weekStart=${encodeURIComponent(weekStart)}`
`/api/roster?weekStart=${encodeURIComponent(weekStart)}`
);
}

Expand Down
49 changes: 35 additions & 14 deletions apps/web/src/app/api/checkout/route.ts
Original file line number Diff line number Diff line change
@@ -1,54 +1,75 @@
import { NextResponse } from "next/server";
import Stripe from "stripe";
import { NextResponse } from 'next/server';
import { requireAuth } from '@/lib/supabase/server';
import Stripe from 'stripe';

const getStripe = () => {
if (
!process.env.STRIPE_SECRET_KEY ||
process.env.STRIPE_SECRET_KEY === "sk_test_placeholder"
process.env.STRIPE_SECRET_KEY === 'sk_test_placeholder'
) {
return null;
}
return new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: "2026-05-27.dahlia" as const,
});
return new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2026-05-27.dahlia' as const,
});
};

export async function POST(req: Request) {
const stripe = getStripe();
if (!stripe) {
return NextResponse.json(
{ error: "Stripe not configured" },
{ error: 'Stripe not configured' },
{ status: 503 },
);
}

try {
const { tenantId, email } = await req.json();
const { user, client } = await requireAuth();
const { email } = await req.json();

// Resolve tenantId from the authenticated user's profile
const { data: profile } = await client
.from('profiles')
.select('tenant_id, role')
.eq('id', user.id)
.single();

if (!profile || !profile.tenant_id) {
return NextResponse.json({ error: 'Profile not found' }, { status: 404 });
}

if (!['owner', 'admin'].includes(profile.role)) {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
}

const session = await stripe.checkout.sessions.create({
payment_method_types: ["card", "au_becs_debit"],
payment_method_types: ['card', 'au_becs_debit'],
customer_email: email,
line_items: [
{
price: process.env.STRIPE_PRICE_ID,
quantity: 1,
},
],
mode: "subscription",
mode: 'subscription',
success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/settings/billing?success=true`,
cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/settings/billing?canceled=true`,
metadata: {
tenantId: tenantId,
tenantId: profile.tenant_id,
},
subscription_data: {
metadata: {
tenantId: tenantId,
tenantId: profile.tenant_id,
},
},
});

return NextResponse.json({ sessionId: session.id, url: session.url });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
} catch (err: unknown) {
if (err instanceof Error && err.message === 'Unauthorized') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const message = err instanceof Error ? err.message : 'Unknown error';
return NextResponse.json({ error: message }, { status: 500 });
}
}
50 changes: 21 additions & 29 deletions apps/web/src/app/api/demo/login/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { randomBytes } from 'crypto';
import { createAdminClient } from '@/lib/supabase/admin';

const DEMO_EMAILS = [
'demo-owner@crewcircle.co',
Expand All @@ -9,6 +9,8 @@ const DEMO_EMAILS = [
'demo-pilot@crewcircle.co',
];

const DEMO_PASSWORD = 'crewcircle-demo-2026';

export async function POST(request: NextRequest) {
try {
const { email, role, tenantId } = await request.json();
Expand All @@ -17,47 +19,37 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Email required' }, { status: 400 });
}

if (DEMO_EMAILS.includes(email)) {
const token = `demo_${randomBytes(32).toString('hex')}`;

return NextResponse.json({
success: true,
token,
userId: `demo_${email.split('@')[0]}`,
role,
tenantId,
});
if (!DEMO_EMAILS.includes(email)) {
return NextResponse.json({ error: 'Not a demo user' }, { status: 400 });
}

const { clerkClient } = await import('@clerk/nextjs/server');
const clerk = await clerkClient();

const users = await clerk.users.getUserList({
emailAddress: [email],
});
const adminClient = createAdminClient();

if (users.data.length === 0) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
// Ensure the demo user exists in Supabase Auth
try {
await adminClient.auth.admin.createUser({
email,
password: DEMO_PASSWORD,
email_confirm: true,
user_metadata: { role, tenant_id: tenantId },
});
} catch (err) {
// User likely already exists — that's fine
}

const userId = users.data[0].id;

const signInToken = await clerk.signInTokens.createSignInToken({
userId,
expiresInSeconds: 3600,
});

// Return credentials for client-side login
return NextResponse.json({
success: true,
token: signInToken.token,
userId,
email,
password: DEMO_PASSWORD,
userId: `demo_${email.split('@')[0]}`,
role,
tenantId,
});
} catch (error) {
console.error('Demo login error:', error);
return NextResponse.json({
error: 'Failed to create sign-in token. ' + (error instanceof Error ? error.message : 'Unknown error'),
error: 'Failed to process demo login. ' + (error instanceof Error ? error.message : 'Unknown error'),
}, { status: 500 });
}
}
Loading
Loading