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
1 change: 1 addition & 0 deletions apps/admin-web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"date-fns": "^4.1.0",
"axios": "^1.13.6",
"clsx": "^2.1.1",
"lucide-react": "^0.563.0",
Expand Down
6 changes: 6 additions & 0 deletions apps/admin-web/src/layouts/AdminLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ const AdminLayout = () => {
icon: Users,
permission: "users",
},
{
label: "Conference Signups",
path: "/admin/conference-waitlist",
icon: UserCheck,
permission: "users",
},
{
label: "Pitch Moderation",
path: "/admin/pitches",
Expand Down
83 changes: 83 additions & 0 deletions apps/admin-web/src/pages/ConferenceWaitlist.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import React, { useEffect, useState } from "react";
import api from "../utils/api";
import { format } from "date-fns";

const ConferenceWaitlist = () => {
const [items, setItems] = useState<any[]>([]);
const [loading, setLoading] = useState(false);

const load = async () => {
setLoading(true);
try {
const res = await api.get("/users/admin/conference-waitlist");
setItems(Array.isArray(res.data) ? res.data : []);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};

useEffect(() => {
void load();
}, []);

const markNotified = async (id: string) => {
try {
await api.patch(`/users/admin/conference-waitlist/${id}/notify`);
setItems((s) =>
s.map((it) =>
it.id === id ? { ...it, conferenceNotifiedAt: new Date().toISOString() } : it,
),
);
} catch (err) {
console.error(err);
alert("Failed to mark user as notified");
}
};

return (
<div className="p-6">
<div className="mb-4 flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold">Conference Signups</h2>
<p className="text-sm text-slate-500">People who signed up in conference mode.</p>
</div>
<button onClick={load} className="px-3 py-2 admin-button-secondary">Refresh</button>
</div>

<div className="overflow-x-auto">
<table className="w-full table-auto border-collapse">
<thead>
<tr className="text-left">
<th className="px-3 py-2">Email</th>
<th className="px-3 py-2">Role</th>
<th className="px-3 py-2">Signed Up</th>
<th className="px-3 py-2">Notified</th>
<th className="px-3 py-2">Actions</th>
</tr>
</thead>
<tbody>
{items.map((it) => (
<tr key={it.id} className="border-t">
<td className="px-3 py-2">{it.email}</td>
<td className="px-3 py-2">{it.role}</td>
<td className="px-3 py-2">{it.createdAt ? format(new Date(it.createdAt), "yyyy-MM-dd") : ""}</td>
<td className="px-3 py-2">{it.conferenceNotifiedAt ? "Yes" : "No"}</td>
<td className="px-3 py-2">
{!it.conferenceNotifiedAt && (
<button onClick={() => markNotified(it.id)} className="px-3 py-1 admin-button-secondary">Mark Notified</button>
)}
</td>
</tr>
))}
</tbody>
</table>
{loading && <div className="mt-4">Loading...</div>}
{!loading && items.length === 0 && <div className="mt-4">No conference signups yet.</div>}
</div>
</div>
);
};

export default ConferenceWaitlist;
45 changes: 45 additions & 0 deletions apps/admin-web/src/pages/Waitlist.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { format } from "date-fns";
const Waitlist = () => {
const [items, setItems] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [selected, setSelected] = useState<Record<string, boolean>>({});
const [inviteMessage, setInviteMessage] = useState('');

const load = async () => {
setLoading(true);
Expand All @@ -31,6 +33,28 @@ const Waitlist = () => {
}
};

const toggleSelect = (id: string) => {
setSelected((s) => ({ ...s, [id]: !s[id] }));
};

const sendInvites = async () => {
const ids = Object.entries(selected).filter(([_, v]) => v).map(([k]) => k);
if (ids.length === 0) return alert('Select at least one user to invite');
if (!confirm(`Send invitations to ${ids.length} user(s)?`)) return;
try {
const res = await api.post('/waitlist/invite', { ids, message: inviteMessage });
// update items
const updatedIds = (res.data.results || []).map((r: any) => r.id);
setItems((s) => s.map((it) => (updatedIds.includes(it.id) ? { ...it, isNotified: true } : it)));
setSelected({});
setInviteMessage('');
alert(`Invitations sent to ${res.data.processed || 0} users.`);
} catch (err) {
console.error(err);
alert('Failed to send invitations');
}
};

const exportCsv = () => {
const header = ["email", "role", "firstName", "lastName", "location", "isNotified", "createdAt"];
const rows = items.map((it) => [
Expand Down Expand Up @@ -66,6 +90,17 @@ const Waitlist = () => {
<table className="w-full table-auto border-collapse">
<thead>
<tr className="text-left">
<th className="px-3 py-2">
<input
type="checkbox"
onChange={(e) => {
const checked = e.target.checked;
const map: Record<string, boolean> = {};
items.forEach((it) => (map[it.id] = checked));
setSelected(map);
}}
/>
</th>
<th className="px-3 py-2">Email</th>
<th className="px-3 py-2">Role</th>
<th className="px-3 py-2">Name</th>
Expand All @@ -78,6 +113,9 @@ const Waitlist = () => {
<tbody>
{items.map((it) => (
<tr key={it.id} className="border-t">
<td className="px-3 py-2">
<input type="checkbox" checked={!!selected[it.id]} onChange={() => toggleSelect(it.id)} />
</td>
<td className="px-3 py-2">{it.email}</td>
<td className="px-3 py-2">{it.role}</td>
<td className="px-3 py-2">{(it.firstName || "") + (it.lastName ? ` ${it.lastName}` : "")}</td>
Expand All @@ -93,6 +131,13 @@ const Waitlist = () => {
))}
</tbody>
</table>
<div className="mt-4">
<textarea placeholder="Optional invite message" value={inviteMessage} onChange={(e) => setInviteMessage(e.target.value)} className="w-full p-2 border rounded mb-2" />
<div className="flex gap-2">
<button onClick={sendInvites} className="px-3 py-2 admin-button">Send Invitations</button>
<button onClick={() => { setSelected({}); setInviteMessage(''); }} className="px-3 py-2 admin-button-secondary">Clear Selection</button>
</div>
</div>
{loading && <div className="mt-4">Loading...</div>}
{!loading && items.length === 0 && <div className="mt-4">No entries yet.</div>}
</div>
Expand Down
7 changes: 7 additions & 0 deletions apps/admin-web/src/router/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ const Verification = lazy(() => import("../pages/Verification"));
const PitchesList = lazy(() => import("../pages/PitchesLists"));
const UsersList = lazy(() => import("../pages/UsersLists"));
const Waitlist = lazy(() => import("../pages/Waitlist"));
const ConferenceWaitlistPage = lazy(
() => import("../pages/ConferenceWaitlist"),
);
const AuditLogs = lazy(() => import("../pages/AuditLogs"));
const Settings = lazy(() => import("../pages/Settings"));
const AcademyPrograms = lazy(() => import("../pages/AcademyPrograms"));
Expand Down Expand Up @@ -60,6 +63,10 @@ export const routes = [
path: "admin/waitlist",
element: withSuspense(<Waitlist />),
},
{
path: "admin/conference-waitlist",
element: withSuspense(<ConferenceWaitlistPage />),
},
],
},
{
Expand Down
1 change: 1 addition & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ APP_BASE_URL=http://localhost:3001
FRONTEND_URL=http://localhost:5173
BACKEND_URL=http://localhost:3000
CORS_ORIGINS=
CONFERENCE_MODE=false

# ---- Certificate ----
CERT_LOGO_URL=
Expand Down
Binary file added apps/api/migration_output.txt
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
ALTER TABLE "User"
ADD COLUMN "isConferenceWaitlist" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "conferenceNotifiedAt" TIMESTAMP(3);

CREATE INDEX "User_isConferenceWaitlist_idx"
ON "User"("isConferenceWaitlist");
2 changes: 2 additions & 0 deletions apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ model User {
emailVerificationExpires DateTime?
passwordResetToken String?
passwordResetExpires DateTime?
isConferenceWaitlist Boolean @default(false)
conferenceNotifiedAt DateTime?
enrollments ProgramEnrollment[]

// Profile Relations
Expand Down
43 changes: 43 additions & 0 deletions apps/api/scripts/apply_migration.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
const fs = require('fs');
const path = require('path');

function loadDotEnv(file) {
const p = path.resolve(file);
if (!fs.existsSync(p)) return;
const content = fs.readFileSync(p, 'utf8');
content.split(/\r?\n/).forEach(line => {
const m = line.match(/^\s*([^#=\s]+)\s*=\s*(.*)\s*$/);
if (m) {
let val = m[2];
if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1);
process.env[m[1]] = val;
}
});
}

loadDotEnv(path.join(__dirname, '..', '.env'));

const { PrismaClient } = require(path.join(__dirname, '..', 'node_modules', '.prisma', 'client'));
const migrationPath = path.join(__dirname, '..', 'prisma', 'migrations', '20260825000000_add_conference_waitlist_fields', 'migration.sql');

(async () => {
const prisma = new PrismaClient();
try {
if (!fs.existsSync(migrationPath)) {
console.error('Migration SQL not found at', migrationPath);
process.exit(2);
}
const sql = fs.readFileSync(migrationPath, 'utf8');
const statements = sql.split(/;\s*\n/).map(s => s.trim()).filter(Boolean);
for (const stmt of statements) {
console.log('Executing:', stmt.substring(0, 120).replace(/\r?\n/g, ' ') + '...');
await prisma.$executeRawUnsafe(stmt);
}
console.log('Migration applied');
} catch (e) {
console.error('Error applying migration:', e && e.message ? e.message : e);
process.exitCode = 2;
} finally {
await prisma.$disconnect();
}
})();
14 changes: 14 additions & 0 deletions apps/api/scripts/check_migration.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
require('dotenv').config({ path: '.env' });
const { PrismaClient } = require('@prisma/client');
(async () => {
const prisma = new PrismaClient();
try {
const res = await prisma.$queryRawUnsafe("SELECT column_name FROM information_schema.columns WHERE table_schema='public' AND table_name='User' AND column_name='isConferenceWaitlist'");
console.log('QUERY_RESULT:', JSON.stringify(res, null, 2));
} catch (e) {
console.error('ERROR:', e && e.message ? e.message : e);
process.exit(1);
} finally {
await prisma.$disconnect();
}
})();
7 changes: 6 additions & 1 deletion apps/api/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,12 @@ export class AuthService {
}

// 3. Create the Token Payload
const payload = { sub: user.id, email: user.email, role: user.role };
const payload = {
sub: user.id,
email: user.email,
role: user.role,
isConferenceWaitlist: Boolean(user.isConferenceWaitlist),
};

// 4. SANITIZE: Remove password from the object we send back
const {
Expand Down
7 changes: 3 additions & 4 deletions apps/api/src/auth/strategies/jwt.strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
}

async validate(payload: JwtPayload): Promise<User> {
// FIX: Using a common UsersService method to find a user by ID.
// ASSUMPTION: Your UsersService has a method like `findById` or `findOne` that takes the ID string directly.
// You might need to adjust `this.usersService.findById(payload.sub)` to your actual method name.
const user = await this.usersService.findById(payload.sub); // <-- CHANGED: Assumed `findById` method
const user = await this.usersService.findById(payload.sub);

if (!user) {
throw new UnauthorizedException('User not found or invalid token.');
Expand All @@ -45,6 +42,8 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
email: user.email,
role: user.role,
adminPermissions: user.adminPermissions,
isConferenceWaitlist: Boolean(user.isConferenceWaitlist),
conferenceNotifiedAt: user.conferenceNotifiedAt ?? null,
} as User;
}
}
30 changes: 30 additions & 0 deletions apps/api/src/users/users.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { CreateUserDto } from './dto/create-user.dto';
import { AdminCreateUserDto } from './dto/admin-create-user.dto';
import { ForgotPasswordDto } from './dto/forgot-password.dto';
import { ResetPasswordDto } from './dto/reset-password.dto';
import { isConferenceMode } from '../utils/conference-mode';

@Controller('users')
export class UsersController {
Expand Down Expand Up @@ -103,9 +104,38 @@ export class UsersController {
if (req.user.role !== UserRole.ADMIN && req.user.id !== id) {
throw new ForbiddenException('You can only view your own stats.');
}

const shouldShowWaitlistView =
isConferenceMode() &&
Boolean(req.user?.isConferenceWaitlist) &&
req.user.role !== UserRole.ADMIN;

if (shouldShowWaitlistView) {
return {
welcome: true,
isConferenceWaitlist: true,
conferenceMode: true,
message: 'You are on the conference waitlist. We will email you when access is ready.',
};
}

return this.usersService.getDashboardStats(id);
}

@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@Get('admin/conference-waitlist')
getConferenceWaitlist() {
return this.usersService.getConferenceWaitlistUsers();
}

@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@Patch('admin/conference-waitlist/:id/notify')
markConferenceWaitlistNotified(@Param('id') id: string) {
return this.usersService.markConferenceWaitlistNotified(id);
}

@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@Get('admin/dashboard')
Expand Down
Loading
Loading