Prettier format pending files

This commit is contained in:
Builder.io 2025-10-18 03:49:38 +00:00
parent e5a5607728
commit df2b52af71
9 changed files with 531 additions and 148 deletions

View file

@ -74,7 +74,10 @@ const App = () => (
<Route path="/feed" element={<Feed />} /> <Route path="/feed" element={<Feed />} />
<Route path="/teams" element={<Teams />} /> <Route path="/teams" element={<Teams />} />
<Route path="/projects/new" element={<ProjectsNew />} /> <Route path="/projects/new" element={<ProjectsNew />} />
<Route path="/projects/:projectId/board" element={<ProjectBoard />} /> <Route
path="/projects/:projectId/board"
element={<ProjectBoard />}
/>
<Route path="/profile" element={<Profile />} /> <Route path="/profile" element={<Profile />} />
<Route path="/profile/me" element={<Profile />} /> <Route path="/profile/me" element={<Profile />} />

View file

@ -1,5 +1,8 @@
import { supabase } from "@/lib/supabase"; import { supabase } from "@/lib/supabase";
import { aethexUserService, aethexNotificationService } from "@/lib/aethex-database-adapter"; import {
aethexUserService,
aethexNotificationService,
} from "@/lib/aethex-database-adapter";
export type TeamVisibility = "public" | "private"; export type TeamVisibility = "public" | "private";
export type MembershipRole = "owner" | "admin" | "member"; export type MembershipRole = "owner" | "admin" | "member";
@ -11,14 +14,21 @@ export const aethexCollabService = {
async listMyTeams(userId: string) { async listMyTeams(userId: string) {
const { data, error } = await supabase const { data, error } = await supabase
.from("team_memberships") .from("team_memberships")
.select("team_id, teams:team_id ( id, name, slug, description, visibility, created_at )") .select(
"team_id, teams:team_id ( id, name, slug, description, visibility, created_at )",
)
.eq("user_id", userId) .eq("user_id", userId)
.order("created_at", { ascending: false }); .order("created_at", { ascending: false });
if (error) return [] as any[]; if (error) return [] as any[];
return (data || []) as any[]; return (data || []) as any[];
}, },
async createTeam(ownerId: string, name: string, description?: string | null, visibility: TeamVisibility = "private") { async createTeam(
ownerId: string,
name: string,
description?: string | null,
visibility: TeamVisibility = "private",
) {
// Ensure the owner has a user_profiles row to satisfy FK // Ensure the owner has a user_profiles row to satisfy FK
try { try {
await aethexUserService.getCurrentUser(); await aethexUserService.getCurrentUser();
@ -26,7 +36,12 @@ export const aethexCollabService = {
const { data, error } = await supabase const { data, error } = await supabase
.from("teams") .from("teams")
.insert({ owner_id: ownerId, name, description: description || null, visibility }) .insert({
owner_id: ownerId,
name,
description: description || null,
visibility,
})
.select() .select()
.single(); .single();
if (error) throw new Error(error.message || "Unable to create team"); if (error) throw new Error(error.message || "Unable to create team");
@ -52,7 +67,11 @@ export const aethexCollabService = {
return team; return team;
}, },
async addTeamMember(teamId: string, userId: string, role: MembershipRole = "member") { async addTeamMember(
teamId: string,
userId: string,
role: MembershipRole = "member",
) {
const { error } = await supabase const { error } = await supabase
.from("team_memberships") .from("team_memberships")
.insert({ team_id: teamId, user_id: userId, role }); .insert({ team_id: teamId, user_id: userId, role });
@ -60,7 +79,11 @@ export const aethexCollabService = {
}, },
// Projects // Projects
async addProjectMember(projectId: string, userId: string, role: ProjectRole = "contributor") { async addProjectMember(
projectId: string,
userId: string,
role: ProjectRole = "contributor",
) {
const { error } = await supabase const { error } = await supabase
.from("project_members") .from("project_members")
.insert({ project_id: projectId, user_id: userId, role }); .insert({ project_id: projectId, user_id: userId, role });
@ -70,7 +93,9 @@ export const aethexCollabService = {
async listProjectMembers(projectId: string) { async listProjectMembers(projectId: string) {
const { data, error } = await supabase const { data, error } = await supabase
.from("project_members") .from("project_members")
.select("user_id, role, user:user_id ( id, full_name, username, avatar_url )") .select(
"user_id, role, user:user_id ( id, full_name, username, avatar_url )",
)
.eq("project_id", projectId); .eq("project_id", projectId);
if (error) return [] as any[]; if (error) return [] as any[];
return (data || []) as any[]; return (data || []) as any[];
@ -87,10 +112,22 @@ export const aethexCollabService = {
return (data || []) as any[]; return (data || []) as any[];
}, },
async createTask(projectId: string, title: string, description?: string | null, assigneeId?: string | null, dueDate?: string | null) { async createTask(
projectId: string,
title: string,
description?: string | null,
assigneeId?: string | null,
dueDate?: string | null,
) {
const { data, error } = await supabase const { data, error } = await supabase
.from("project_tasks") .from("project_tasks")
.insert({ project_id: projectId, title, description: description || null, assignee_id: assigneeId || null, due_date: dueDate || null }) .insert({
project_id: projectId,
title,
description: description || null,
assignee_id: assigneeId || null,
due_date: dueDate || null,
})
.select() .select()
.single(); .single();
if (error) throw new Error(error.message || "Unable to create task"); if (error) throw new Error(error.message || "Unable to create task");

View file

@ -56,7 +56,10 @@ export const aethexSocialService = {
const resp = await fetch("/api/social/follow", { const resp = await fetch("/api/social/follow", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ follower_id: followerId, following_id: followingId }), body: JSON.stringify({
follower_id: followerId,
following_id: followingId,
}),
}); });
if (!resp.ok) throw new Error(await resp.text()); if (!resp.ok) throw new Error(await resp.text());
}, },
@ -65,7 +68,10 @@ export const aethexSocialService = {
const resp = await fetch("/api/social/unfollow", { const resp = await fetch("/api/social/unfollow", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ follower_id: followerId, following_id: followingId }), body: JSON.stringify({
follower_id: followerId,
following_id: followingId,
}),
}); });
if (!resp.ok) throw new Error(await resp.text()); if (!resp.ok) throw new Error(await resp.text());
}, },
@ -148,7 +154,11 @@ export const aethexSocialService = {
const resp = await fetch("/api/social/endorse", { const resp = await fetch("/api/social/endorse", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endorser_id: endorserId, endorsed_id: endorsedId, skill }), body: JSON.stringify({
endorser_id: endorserId,
endorsed_id: endorsedId,
skill,
}),
}); });
if (!resp.ok) throw new Error(await resp.text()); if (!resp.ok) throw new Error(await resp.text());
}, },

View file

@ -509,10 +509,30 @@ export default function Dashboard() {
const showProfileSetup = !profileComplete; const showProfileSetup = !profileComplete;
const statsDisplay = [ const statsDisplay = [
{ label: "Active Projects", value: stats.activeProjects, icon: Rocket, color: "from-blue-500 to-purple-600" }, {
{ label: "Completed Tasks", value: stats.completedTasks, icon: Trophy, color: "from-green-500 to-blue-600" }, label: "Active Projects",
{ label: "Team Members", value: stats.teamMembers, icon: Users, color: "from-purple-500 to-pink-600" }, value: stats.activeProjects,
{ label: "Performance Score", value: stats.performanceScore, icon: TrendingUp, color: "from-orange-500 to-red-600" }, icon: Rocket,
color: "from-blue-500 to-purple-600",
},
{
label: "Completed Tasks",
value: stats.completedTasks,
icon: Trophy,
color: "from-green-500 to-blue-600",
},
{
label: "Team Members",
value: stats.teamMembers,
icon: Users,
color: "from-purple-500 to-pink-600",
},
{
label: "Performance Score",
value: stats.performanceScore,
icon: TrendingUp,
color: "from-orange-500 to-red-600",
},
]; ];
const getProgressPercentage = (project: any) => { const getProgressPercentage = (project: any) => {
@ -561,10 +581,18 @@ export default function Dashboard() {
}; };
const quickActions = [ const quickActions = [
{ title: "Start New Project", icon: Rocket, color: "from-blue-500 to-purple-600" }, {
title: "Start New Project",
icon: Rocket,
color: "from-blue-500 to-purple-600",
},
{ title: "Create Team", icon: Users, color: "from-green-500 to-blue-600" }, { title: "Create Team", icon: Users, color: "from-green-500 to-blue-600" },
{ title: "Access Labs", icon: Zap, color: "from-yellow-500 to-orange-600" }, { title: "Access Labs", icon: Zap, color: "from-yellow-500 to-orange-600" },
{ title: "View Analytics", icon: BarChart3, color: "from-purple-500 to-pink-600" }, {
title: "View Analytics",
icon: BarChart3,
color: "from-purple-500 to-pink-600",
},
]; ];
if (isLoading) { if (isLoading) {
@ -1200,7 +1228,12 @@ export default function Dashboard() {
Your active development projects Your active development projects
</CardDescription> </CardDescription>
</div> </div>
<Button variant="outline" size="sm" className="hover-lift" onClick={() => navigate("/projects")}> <Button
variant="outline"
size="sm"
className="hover-lift"
onClick={() => navigate("/projects")}
>
View All View All
<ChevronRight className="h-4 w-4 ml-1" /> <ChevronRight className="h-4 w-4 ml-1" />
</Button> </Button>
@ -1266,7 +1299,13 @@ export default function Dashboard() {
> >
{getPriorityFromTech(project.technologies || [])} {getPriorityFromTech(project.technologies || [])}
</Badge> </Badge>
<Button variant="outline" size="sm" onClick={() => navigate(`/projects/${project.id}/board`)}> <Button
variant="outline"
size="sm"
onClick={() =>
navigate(`/projects/${project.id}/board`)
}
>
Open Board Open Board
</Button> </Button>
</div> </div>
@ -1406,14 +1445,27 @@ export default function Dashboard() {
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{teams.length === 0 ? ( {teams.length === 0 ? (
<div className="text-sm text-muted-foreground">No teams yet.</div> <div className="text-sm text-muted-foreground">
No teams yet.
</div>
) : ( ) : (
teams.slice(0, 6).map((t: any) => { teams.slice(0, 6).map((t: any) => {
const team = (t as any).teams || t; const team = (t as any).teams || t;
return ( return (
<div key={team.id} className="flex items-center justify-between p-3 rounded border border-border/40"> <div
<div className="font-medium text-sm">{team.name}</div> key={team.id}
<Button variant="ghost" size="sm" onClick={() => navigate("/teams")}>Open</Button> className="flex items-center justify-between p-3 rounded border border-border/40"
>
<div className="font-medium text-sm">
{team.name}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => navigate("/teams")}
>
Open
</Button>
</div> </div>
); );
}) })
@ -1428,15 +1480,34 @@ export default function Dashboard() {
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{invites.length === 0 ? ( {invites.length === 0 ? (
<div className="text-sm text-muted-foreground">No invites yet.</div> <div className="text-sm text-muted-foreground">
No invites yet.
</div>
) : ( ) : (
invites.slice(0, 6).map((inv: any) => ( invites.slice(0, 6).map((inv: any) => (
<div key={inv.id} className="flex items-center justify-between p-3 rounded border border-border/40"> <div
key={inv.id}
className="flex items-center justify-between p-3 rounded border border-border/40"
>
<div className="text-sm"> <div className="text-sm">
<div className="font-medium">{inv.invitee_email}</div> <div className="font-medium">
<div className="text-xs text-muted-foreground">{inv.status}</div> {inv.invitee_email}
</div>
<div className="text-xs text-muted-foreground">
{inv.status}
</div>
</div> </div>
<Button variant="ghost" size="sm" onClick={() => navigator.clipboard.writeText(`${location.origin}/login?invite=${inv.token}`)}>Copy link</Button> <Button
variant="ghost"
size="sm"
onClick={() =>
navigator.clipboard.writeText(
`${location.origin}/login?invite=${inv.token}`,
)
}
>
Copy link
</Button>
</div> </div>
)) ))
)} )}

View file

@ -6,39 +6,68 @@ export default function Privacy() {
<div className="min-h-screen bg-aethex-gradient py-12"> <div className="min-h-screen bg-aethex-gradient py-12">
<div className="container mx-auto px-4 max-w-4xl space-y-8"> <div className="container mx-auto px-4 max-w-4xl space-y-8">
<header className="space-y-2"> <header className="space-y-2">
<h1 className="text-3xl font-bold text-gradient-purple">Privacy Policy</h1> <h1 className="text-3xl font-bold text-gradient-purple">
<p className="text-sm text-muted-foreground">Effective date: 2025-10-18</p> Privacy Policy
</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
This Privacy Policy explains how AeThex ("we", "us") collects, uses, shares, and protects Effective date: 2025-10-18
information when you use our products, sites, and services (the "Services"). </p>
<p className="text-sm text-muted-foreground">
This Privacy Policy explains how AeThex ("we", "us") collects,
uses, shares, and protects information when you use our products,
sites, and services (the "Services").
</p> </p>
</header> </header>
<section className="space-y-3"> <section className="space-y-3">
<h2 className="font-semibold">Information We Collect</h2> <h2 className="font-semibold">Information We Collect</h2>
<ul className="list-disc pl-5 text-sm text-muted-foreground space-y-1"> <ul className="list-disc pl-5 text-sm text-muted-foreground space-y-1">
<li>Account data: name, username, email, profile details, social links.</li> <li>
<li>Content: posts, comments, projects, teams, endorsements, activity metadata.</li> Account data: name, username, email, profile details, social
<li>Usage data: device/browser information, pages visited, interactions, approximate location.</li> links.
<li>Cookies & similar: session and preference cookies for authentication and settings.</li> </li>
<li>
Content: posts, comments, projects, teams, endorsements,
activity metadata.
</li>
<li>
Usage data: device/browser information, pages visited,
interactions, approximate location.
</li>
<li>
Cookies & similar: session and preference cookies for
authentication and settings.
</li>
</ul> </ul>
</section> </section>
<section className="space-y-3"> <section className="space-y-3">
<h2 className="font-semibold">How We Use Information</h2> <h2 className="font-semibold">How We Use Information</h2>
<ul className="list-disc pl-5 text-sm text-muted-foreground space-y-1"> <ul className="list-disc pl-5 text-sm text-muted-foreground space-y-1">
<li>Provide and improve the Services, including social, projects, teams, and notifications.</li> <li>
<li>Security, abuse prevention, fraud detection, and diagnostics.</li> Provide and improve the Services, including social, projects,
<li>Personalization (e.g., recommendations, feed ranking) and aggregated analytics.</li> teams, and notifications.
<li>Communications: transactional emails (verification, invites, alerts) and product updates.</li> </li>
<li>
Security, abuse prevention, fraud detection, and diagnostics.
</li>
<li>
Personalization (e.g., recommendations, feed ranking) and
aggregated analytics.
</li>
<li>
Communications: transactional emails (verification, invites,
alerts) and product updates.
</li>
</ul> </ul>
</section> </section>
<section className="space-y-3"> <section className="space-y-3">
<h2 className="font-semibold">Legal Bases (EEA/UK)</h2> <h2 className="font-semibold">Legal Bases (EEA/UK)</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
We process data under: (i) Performance of a contract (providing core features), (ii) Legitimate We process data under: (i) Performance of a contract (providing
interests (security, analytics, product improvement), (iii) Consent (where required), and (iv) core features), (ii) Legitimate interests (security, analytics,
product improvement), (iii) Consent (where required), and (iv)
Compliance with legal obligations. Compliance with legal obligations.
</p> </p>
</section> </section>
@ -46,8 +75,9 @@ export default function Privacy() {
<section className="space-y-3"> <section className="space-y-3">
<h2 className="font-semibold">Sharing & Service Providers</h2> <h2 className="font-semibold">Sharing & Service Providers</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
We do not sell your personal information. We use trusted sub-processors to operate the platform: We do not sell your personal information. We use trusted
Supabase (auth, database, storage), Vercel/Netlify (hosting/CDN), and Resend (email). These sub-processors to operate the platform: Supabase (auth, database,
storage), Vercel/Netlify (hosting/CDN), and Resend (email). These
providers process data on our behalf under appropriate agreements. providers process data on our behalf under appropriate agreements.
</p> </p>
</section> </section>
@ -55,24 +85,31 @@ export default function Privacy() {
<section className="space-y-3"> <section className="space-y-3">
<h2 className="font-semibold">International Transfers</h2> <h2 className="font-semibold">International Transfers</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Data may be processed in the United States and other countries. Where applicable, we rely on Data may be processed in the United States and other countries.
appropriate safeguards (e.g., SCCs) for cross-border transfers. Where applicable, we rely on appropriate safeguards (e.g., SCCs)
for cross-border transfers.
</p> </p>
</section> </section>
<section className="space-y-3"> <section className="space-y-3">
<h2 className="font-semibold">Data Retention</h2> <h2 className="font-semibold">Data Retention</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
We retain data for as long as needed to provide Services, comply with law, resolve disputes, We retain data for as long as needed to provide Services, comply
and enforce agreements. You may request deletion of your account data, subject to legal holds. with law, resolve disputes, and enforce agreements. You may
request deletion of your account data, subject to legal holds.
</p> </p>
</section> </section>
<section className="space-y-3"> <section className="space-y-3">
<h2 className="font-semibold">Your Rights</h2> <h2 className="font-semibold">Your Rights</h2>
<ul className="list-disc pl-5 text-sm text-muted-foreground space-y-1"> <ul className="list-disc pl-5 text-sm text-muted-foreground space-y-1">
<li>Access, correction, deletion, and portability of your data.</li> <li>
<li>Object to or restrict certain processing; withdraw consent where applicable.</li> Access, correction, deletion, and portability of your data.
</li>
<li>
Object to or restrict certain processing; withdraw consent where
applicable.
</li>
<li>Manage notifications and email preferences in-app.</li> <li>Manage notifications and email preferences in-app.</li>
</ul> </ul>
</section> </section>
@ -80,31 +117,35 @@ export default function Privacy() {
<section className="space-y-3"> <section className="space-y-3">
<h2 className="font-semibold">Security</h2> <h2 className="font-semibold">Security</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
We use industry-standard measures to protect data in transit and at rest. No method of We use industry-standard measures to protect data in transit and
transmission or storage is 100% secure; you are responsible for safeguarding credentials. at rest. No method of transmission or storage is 100% secure; you
are responsible for safeguarding credentials.
</p> </p>
</section> </section>
<section className="space-y-3"> <section className="space-y-3">
<h2 className="font-semibold">Children</h2> <h2 className="font-semibold">Children</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Our Services are not directed to children under 13 (or as defined by local law). We do not Our Services are not directed to children under 13 (or as defined
knowingly collect data from children. If you believe a child has provided data, contact us. by local law). We do not knowingly collect data from children. If
you believe a child has provided data, contact us.
</p> </p>
</section> </section>
<section className="space-y-3"> <section className="space-y-3">
<h2 className="font-semibold">Changes</h2> <h2 className="font-semibold">Changes</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
We may update this Policy. Material changes will be announced via the app or email. Your We may update this Policy. Material changes will be announced via
continued use after changes constitutes acceptance. the app or email. Your continued use after changes constitutes
acceptance.
</p> </p>
</section> </section>
<section className="space-y-3"> <section className="space-y-3">
<h2 className="font-semibold">Contact</h2> <h2 className="font-semibold">Contact</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
For privacy inquiries: privacy@aethex.biz. For support: support@aethex.biz. For privacy inquiries: privacy@aethex.biz. For support:
support@aethex.biz.
</p> </p>
</section> </section>
</div> </div>

View file

@ -2,7 +2,13 @@ import Layout from "@/components/Layout";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card"; import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
@ -10,7 +16,11 @@ import { Badge } from "@/components/ui/badge";
import { aethexCollabService } from "@/lib/aethex-collab-service"; import { aethexCollabService } from "@/lib/aethex-collab-service";
import LoadingScreen from "@/components/LoadingScreen"; import LoadingScreen from "@/components/LoadingScreen";
const columns: { key: "todo"|"doing"|"done"|"blocked"; title: string; hint: string }[] = [ const columns: {
key: "todo" | "doing" | "done" | "blocked";
title: string;
hint: string;
}[] = [
{ key: "todo", title: "To do", hint: "Planned" }, { key: "todo", title: "To do", hint: "Planned" },
{ key: "doing", title: "In progress", hint: "Active" }, { key: "doing", title: "In progress", hint: "Active" },
{ key: "done", title: "Done", hint: "Completed" }, { key: "done", title: "Done", hint: "Completed" },
@ -47,7 +57,12 @@ export default function ProjectBoard() {
}, [projectId]); }, [projectId]);
const grouped = useMemo(() => { const grouped = useMemo(() => {
const map: Record<string, any[]> = { todo: [], doing: [], done: [], blocked: [] }; const map: Record<string, any[]> = {
todo: [],
doing: [],
done: [],
blocked: [],
};
for (const t of tasks) { for (const t of tasks) {
map[t.status || "todo"].push(t); map[t.status || "todo"].push(t);
} }
@ -59,7 +74,13 @@ export default function ProjectBoard() {
if (!title.trim()) return; if (!title.trim()) return;
setCreating(true); setCreating(true);
try { try {
await aethexCollabService.createTask(projectId, title.trim(), description.trim() || null, null, null); await aethexCollabService.createTask(
projectId,
title.trim(),
description.trim() || null,
null,
null,
);
setTitle(""); setTitle("");
setDescription(""); setDescription("");
await load(); await load();
@ -68,12 +89,18 @@ export default function ProjectBoard() {
} }
}; };
const move = async (taskId: string, status: "todo"|"doing"|"done"|"blocked") => { const move = async (
taskId: string,
status: "todo" | "doing" | "done" | "blocked",
) => {
await aethexCollabService.updateTaskStatus(taskId, status); await aethexCollabService.updateTaskStatus(taskId, status);
await load(); await load();
}; };
if (loading || isLoading) return <LoadingScreen message="Loading project..." showProgress duration={800} />; if (loading || isLoading)
return (
<LoadingScreen message="Loading project..." showProgress duration={800} />
);
if (!user) return null; if (!user) return null;
return ( return (
@ -81,17 +108,29 @@ export default function ProjectBoard() {
<div className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(110,141,255,0.12),transparent_60%)] py-10"> <div className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(110,141,255,0.12),transparent_60%)] py-10">
<div className="mx-auto w-full max-w-6xl px-4 lg:px-6 space-y-6"> <div className="mx-auto w-full max-w-6xl px-4 lg:px-6 space-y-6">
<section className="rounded-3xl border border-border/40 bg-background/80 p-6 shadow-2xl backdrop-blur"> <section className="rounded-3xl border border-border/40 bg-background/80 p-6 shadow-2xl backdrop-blur">
<h1 className="text-3xl font-semibold text-foreground">Project Board</h1> <h1 className="text-3xl font-semibold text-foreground">
<p className="mt-1 text-sm text-muted-foreground">Track tasks by status. Drag-and-drop coming next.</p> Project Board
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Track tasks by status. Drag-and-drop coming next.
</p>
</section> </section>
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)]"> <div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)]">
{columns.map((col) => ( {columns.map((col) => (
<Card key={col.key} className="rounded-3xl border-border/40 bg-background/70 shadow-xl backdrop-blur-lg"> <Card
key={col.key}
className="rounded-3xl border-border/40 bg-background/70 shadow-xl backdrop-blur-lg"
>
<CardHeader> <CardHeader>
<CardTitle className="text-base flex items-center gap-2"> <CardTitle className="text-base flex items-center gap-2">
{col.title} {col.title}
<Badge variant="outline" className="border-border/50 text-xs">{grouped[col.key].length}</Badge> <Badge
variant="outline"
className="border-border/50 text-xs"
>
{grouped[col.key].length}
</Badge>
</CardTitle> </CardTitle>
<CardDescription>{col.hint}</CardDescription> <CardDescription>{col.hint}</CardDescription>
</CardHeader> </CardHeader>
@ -100,14 +139,26 @@ export default function ProjectBoard() {
<p className="text-sm text-muted-foreground">No tasks.</p> <p className="text-sm text-muted-foreground">No tasks.</p>
) : ( ) : (
grouped[col.key].map((t) => ( grouped[col.key].map((t) => (
<div key={t.id} className="rounded-2xl border border-border/30 bg-background/60 p-3"> <div
<div className="font-medium text-foreground">{t.title}</div> key={t.id}
className="rounded-2xl border border-border/30 bg-background/60 p-3"
>
<div className="font-medium text-foreground">
{t.title}
</div>
{t.description ? ( {t.description ? (
<p className="text-xs text-muted-foreground mt-1">{t.description}</p> <p className="text-xs text-muted-foreground mt-1">
{t.description}
</p>
) : null} ) : null}
<div className="mt-2 flex flex-wrap gap-2"> <div className="mt-2 flex flex-wrap gap-2">
{columns.map((k) => ( {columns.map((k) => (
<Button key={`${t.id}-${k.key}`} size="xs" variant="outline" onClick={() => move(t.id, k.key)}> <Button
key={`${t.id}-${k.key}`}
size="xs"
variant="outline"
onClick={() => move(t.id, k.key)}
>
{k.title} {k.title}
</Button> </Button>
))} ))}
@ -123,13 +174,27 @@ export default function ProjectBoard() {
<Card className="rounded-3xl border-border/40 bg-background/70 shadow-xl backdrop-blur-lg"> <Card className="rounded-3xl border-border/40 bg-background/70 shadow-xl backdrop-blur-lg">
<CardHeader> <CardHeader>
<CardTitle className="text-lg">Add task</CardTitle> <CardTitle className="text-lg">Add task</CardTitle>
<CardDescription>Keep titles concise; details optional.</CardDescription> <CardDescription>
Keep titles concise; details optional.
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<Input placeholder="Task title" value={title} onChange={(e) => setTitle(e.target.value)} /> <Input
<Textarea placeholder="Description (optional)" value={description} onChange={(e) => setDescription(e.target.value)} /> placeholder="Task title"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<Textarea
placeholder="Description (optional)"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<div className="flex justify-end"> <div className="flex justify-end">
<Button onClick={handleCreate} disabled={creating || !title.trim()} className="rounded-full bg-gradient-to-r from-aethex-500 to-neon-blue text-white"> <Button
onClick={handleCreate}
disabled={creating || !title.trim()}
className="rounded-full bg-gradient-to-r from-aethex-500 to-neon-blue text-white"
>
{creating ? "Creating..." : "Create task"} {creating ? "Creating..." : "Create task"}
</Button> </Button>
</div> </div>

View file

@ -2,7 +2,13 @@ import Layout from "@/components/Layout";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card"; import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
@ -39,31 +45,57 @@ export default function Teams() {
load(); load();
}, [user?.id]); }, [user?.id]);
const canCreate = useMemo(() => name.trim().length > 2 && !creating, [name, creating]); const canCreate = useMemo(
() => name.trim().length > 2 && !creating,
[name, creating],
);
const handleCreate = async () => { const handleCreate = async () => {
if (!user?.id) return; if (!user?.id) return;
if (!canCreate) return; if (!canCreate) return;
setCreating(true); setCreating(true);
const tempId = `temp-${Date.now()}`; const tempId = `temp-${Date.now()}`;
const optimistic = { team_id: tempId, teams: { id: tempId, name: name.trim(), description: description.trim() || null, visibility: "private" } } as any; const optimistic = {
team_id: tempId,
teams: {
id: tempId,
name: name.trim(),
description: description.trim() || null,
visibility: "private",
},
} as any;
setTeams((prev) => [optimistic, ...prev]); setTeams((prev) => [optimistic, ...prev]);
setName(""); setName("");
setDescription(""); setDescription("");
let created: any | null = null; let created: any | null = null;
try { try {
created = await aethexCollabService.createTeam(user.id, optimistic.teams.name, optimistic.teams.description, "private"); created = await aethexCollabService.createTeam(
setTeams((prev) => prev.map((t: any) => (t.team_id === tempId ? { team_id: created.id, teams: created } : t))); user.id,
optimistic.teams.name,
optimistic.teams.description,
"private",
);
setTeams((prev) =>
prev.map((t: any) =>
t.team_id === tempId ? { team_id: created.id, teams: created } : t,
),
);
aethexToast.success({ title: "Team created" }); aethexToast.success({ title: "Team created" });
} catch (e: any) { } catch (e: any) {
setTeams((prev) => prev.filter((t: any) => t.team_id !== tempId)); setTeams((prev) => prev.filter((t: any) => t.team_id !== tempId));
aethexToast.error({ title: "Failed to create team", description: e?.message || "Try again later." }); aethexToast.error({
title: "Failed to create team",
description: e?.message || "Try again later.",
});
} finally { } finally {
setCreating(false); setCreating(false);
} }
}; };
if (loading || isLoading) return <LoadingScreen message="Loading teams..." showProgress duration={800} />; if (loading || isLoading)
return (
<LoadingScreen message="Loading teams..." showProgress duration={800} />
);
if (!user) return null; if (!user) return null;
@ -73,7 +105,9 @@ export default function Teams() {
<div className="mx-auto w-full max-w-6xl px-4 lg:px-6 space-y-6"> <div className="mx-auto w-full max-w-6xl px-4 lg:px-6 space-y-6">
<section className="rounded-3xl border border-border/40 bg-background/80 p-6 shadow-2xl backdrop-blur"> <section className="rounded-3xl border border-border/40 bg-background/80 p-6 shadow-2xl backdrop-blur">
<h1 className="text-3xl font-semibold text-foreground">Teams</h1> <h1 className="text-3xl font-semibold text-foreground">Teams</h1>
<p className="mt-1 text-sm text-muted-foreground">Create a team and collaborate with members across projects.</p> <p className="mt-1 text-sm text-muted-foreground">
Create a team and collaborate with members across projects.
</p>
</section> </section>
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]"> <div className="grid gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]">
@ -85,25 +119,47 @@ export default function Teams() {
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{teams.length === 0 ? ( {teams.length === 0 ? (
<p className="text-sm text-muted-foreground">No teams yet. Create one to get started.</p> <p className="text-sm text-muted-foreground">
No teams yet. Create one to get started.
</p>
) : ( ) : (
teams.map((t) => { teams.map((t) => {
const team = (t as any).teams || t; const team = (t as any).teams || t;
return ( return (
<div key={team.id} className="flex items-center justify-between rounded-2xl border border-border/30 bg-background/60 p-4"> <div
key={team.id}
className="flex items-center justify-between rounded-2xl border border-border/30 bg-background/60 p-4"
>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Avatar className="h-10 w-10"> <Avatar className="h-10 w-10">
<AvatarImage src={undefined} /> <AvatarImage src={undefined} />
<AvatarFallback>{team.name?.[0]?.toUpperCase() || "T"}</AvatarFallback> <AvatarFallback>
{team.name?.[0]?.toUpperCase() || "T"}
</AvatarFallback>
</Avatar> </Avatar>
<div> <div>
<div className="font-medium text-foreground">{team.name}</div> <div className="font-medium text-foreground">
<div className="text-xs text-muted-foreground">{team.visibility || "private"}</div> {team.name}
</div>
<div className="text-xs text-muted-foreground">
{team.visibility || "private"}
</div>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant="outline" className="border-border/50">Team</Badge> <Badge
<Button variant="outline" size="sm" onClick={() => navigate(`/projects/new`)}>New project</Button> variant="outline"
className="border-border/50"
>
Team
</Badge>
<Button
variant="outline"
size="sm"
onClick={() => navigate(`/projects/new`)}
>
New project
</Button>
</div> </div>
</div> </div>
); );
@ -117,13 +173,27 @@ export default function Teams() {
<Card className="rounded-3xl border-border/40 bg-background/70 shadow-xl backdrop-blur-lg"> <Card className="rounded-3xl border-border/40 bg-background/70 shadow-xl backdrop-blur-lg">
<CardHeader> <CardHeader>
<CardTitle className="text-lg">Create a team</CardTitle> <CardTitle className="text-lg">Create a team</CardTitle>
<CardDescription>Private by default; you can invite members later.</CardDescription> <CardDescription>
Private by default; you can invite members later.
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<Input placeholder="Team name" value={name} onChange={(e) => setName(e.target.value)} /> <Input
<Textarea placeholder="Short description (optional)" value={description} onChange={(e) => setDescription(e.target.value)} /> placeholder="Team name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<Textarea
placeholder="Short description (optional)"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<div className="flex justify-end"> <div className="flex justify-end">
<Button onClick={handleCreate} disabled={!canCreate} className="rounded-full bg-gradient-to-r from-aethex-500 to-neon-blue text-white"> <Button
onClick={handleCreate}
disabled={!canCreate}
className="rounded-full bg-gradient-to-r from-aethex-500 to-neon-blue text-white"
>
{creating ? "Creating..." : "Create team"} {creating ? "Creating..." : "Create team"}
</Button> </Button>
</div> </div>

View file

@ -6,29 +6,36 @@ export default function Terms() {
<div className="min-h-screen bg-aethex-gradient py-12"> <div className="min-h-screen bg-aethex-gradient py-12">
<div className="container mx-auto px-4 max-w-4xl space-y-8"> <div className="container mx-auto px-4 max-w-4xl space-y-8">
<header className="space-y-2"> <header className="space-y-2">
<h1 className="text-3xl font-bold text-gradient-purple">Terms of Service</h1> <h1 className="text-3xl font-bold text-gradient-purple">
<p className="text-sm text-muted-foreground">Effective date: 2025-10-18</p> Terms of Service
</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
These Terms govern your access to and use of the AeThex Services. By using the Services, Effective date: 2025-10-18
you agree to these Terms. </p>
<p className="text-sm text-muted-foreground">
These Terms govern your access to and use of the AeThex Services.
By using the Services, you agree to these Terms.
</p> </p>
</header> </header>
<section className="space-y-3"> <section className="space-y-3">
<h2 className="font-semibold">Accounts & Eligibility</h2> <h2 className="font-semibold">Accounts & Eligibility</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
You must be at least 13 years old (or the age required by your jurisdiction). You are You must be at least 13 years old (or the age required by your
responsible for maintaining the confidentiality of your credentials and for all activity jurisdiction). You are responsible for maintaining the
under your account. confidentiality of your credentials and for all activity under
your account.
</p> </p>
</section> </section>
<section className="space-y-3"> <section className="space-y-3">
<h2 className="font-semibold">User Content & License</h2> <h2 className="font-semibold">User Content & License</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
You retain ownership of content you submit. You grant AeThex a worldwide, non-exclusive, You retain ownership of content you submit. You grant AeThex a
royalty-free license to host, store, reproduce, and display your content solely to operate worldwide, non-exclusive, royalty-free license to host, store,
and improve the Services. You represent you have the rights to submit such content. reproduce, and display your content solely to operate and improve
the Services. You represent you have the rights to submit such
content.
</p> </p>
</section> </section>
@ -38,31 +45,37 @@ export default function Terms() {
<li>No unlawful, infringing, or deceptive activity.</li> <li>No unlawful, infringing, or deceptive activity.</li>
<li>No harassment, hate, or exploitation.</li> <li>No harassment, hate, or exploitation.</li>
<li>No malware, spam, scraping, or abuse of API limits.</li> <li>No malware, spam, scraping, or abuse of API limits.</li>
<li>No attempts to access others accounts or data without permission.</li> <li>
No attempts to access others accounts or data without
permission.
</li>
</ul> </ul>
</section> </section>
<section className="space-y-3"> <section className="space-y-3">
<h2 className="font-semibold">Projects, Teams, and Matching</h2> <h2 className="font-semibold">Projects, Teams, and Matching</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Collaboration features (projects, teams, tasks, endorsements) are provided as-is. Team owners Collaboration features (projects, teams, tasks, endorsements) are
and project owners are responsible for membership, roles, and shared materials. provided as-is. Team owners and project owners are responsible for
membership, roles, and shared materials.
</p> </p>
</section> </section>
<section className="space-y-3"> <section className="space-y-3">
<h2 className="font-semibold">Third-Party Services</h2> <h2 className="font-semibold">Third-Party Services</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
We may integrate third-party services (e.g., Supabase, Resend, hosting/CDN). Your use of We may integrate third-party services (e.g., Supabase, Resend,
those services is subject to their terms and policies. hosting/CDN). Your use of those services is subject to their terms
and policies.
</p> </p>
</section> </section>
<section className="space-y-3"> <section className="space-y-3">
<h2 className="font-semibold">Intellectual Property</h2> <h2 className="font-semibold">Intellectual Property</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
AeThex and its licensors retain all rights to the platform, branding, and software. You may AeThex and its licensors retain all rights to the platform,
not copy, modify, or distribute platform code or assets except as permitted by law or written branding, and software. You may not copy, modify, or distribute
platform code or assets except as permitted by law or written
authorization. authorization.
</p> </p>
</section> </section>
@ -70,16 +83,19 @@ export default function Terms() {
<section className="space-y-3"> <section className="space-y-3">
<h2 className="font-semibold">Termination</h2> <h2 className="font-semibold">Termination</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
We may suspend or terminate your access for violations or risk to the platform. You may We may suspend or terminate your access for violations or risk to
discontinue use at any time. the platform. You may discontinue use at any time.
</p> </p>
</section> </section>
<section className="space-y-3"> <section className="space-y-3">
<h2 className="font-semibold">Disclaimers & Limitation of Liability</h2> <h2 className="font-semibold">
Disclaimers & Limitation of Liability
</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
The Services are provided as-is without warranties. To the maximum extent permitted by law, The Services are provided as-is without warranties. To the
AeThex will not be liable for indirect, incidental, or consequential damages arising from or maximum extent permitted by law, AeThex will not be liable for
indirect, incidental, or consequential damages arising from or
related to your use of the Services. related to your use of the Services.
</p> </p>
</section> </section>
@ -87,24 +103,25 @@ export default function Terms() {
<section className="space-y-3"> <section className="space-y-3">
<h2 className="font-semibold">Indemnification</h2> <h2 className="font-semibold">Indemnification</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
You agree to indemnify and hold AeThex harmless from claims arising out of your content or You agree to indemnify and hold AeThex harmless from claims
misuse of the Services. arising out of your content or misuse of the Services.
</p> </p>
</section> </section>
<section className="space-y-3"> <section className="space-y-3">
<h2 className="font-semibold">Changes to Terms</h2> <h2 className="font-semibold">Changes to Terms</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
We may update these Terms. Material changes will be announced via the app or email. Your We may update these Terms. Material changes will be announced via
continued use constitutes acceptance of updated Terms. the app or email. Your continued use constitutes acceptance of
updated Terms.
</p> </p>
</section> </section>
<section className="space-y-3"> <section className="space-y-3">
<h2 className="font-semibold">Governing Law & Contact</h2> <h2 className="font-semibold">Governing Law & Contact</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
These Terms are governed by applicable laws of the United States. For questions, contact These Terms are governed by applicable laws of the United States.
legal@aethex.biz. For questions, contact legal@aethex.biz.
</p> </p>
</section> </section>
</div> </div>

View file

@ -720,7 +720,9 @@ export function createServer() {
} }
} }
await accrue(inviter_id, "loyalty", 5, "invite_sent", { invitee: email }); await accrue(inviter_id, "loyalty", 5, "invite_sent", {
invitee: email,
});
try { try {
await adminSupabase.from("notifications").insert({ await adminSupabase.from("notifications").insert({
user_id: inviter_id, user_id: inviter_id,
@ -799,7 +801,9 @@ export function createServer() {
if (inviterId) { if (inviterId) {
await accrue(inviterId, "xp", 100, "invite_accepted", { token }); await accrue(inviterId, "xp", 100, "invite_accepted", { token });
await accrue(inviterId, "loyalty", 50, "invite_accepted", { token }); await accrue(inviterId, "loyalty", 50, "invite_accepted", { token });
await accrue(inviterId, "reputation", 2, "invite_accepted", { token }); await accrue(inviterId, "reputation", 2, "invite_accepted", {
token,
});
try { try {
await adminSupabase.from("notifications").insert({ await adminSupabase.from("notifications").insert({
user_id: inviterId, user_id: inviterId,
@ -810,7 +814,9 @@ export function createServer() {
} catch {} } catch {}
} }
await accrue(acceptor_id, "xp", 50, "invite_accepted", { token }); await accrue(acceptor_id, "xp", 50, "invite_accepted", { token });
await accrue(acceptor_id, "reputation", 1, "invite_accepted", { token }); await accrue(acceptor_id, "reputation", 1, "invite_accepted", {
token,
});
try { try {
await adminSupabase.from("notifications").insert({ await adminSupabase.from("notifications").insert({
user_id: acceptor_id, user_id: acceptor_id,
@ -828,20 +834,32 @@ export function createServer() {
// Follow/unfollow with notifications // Follow/unfollow with notifications
app.post("/api/social/follow", async (req, res) => { app.post("/api/social/follow", async (req, res) => {
const { follower_id, following_id } = (req.body || {}) as { follower_id?: string; following_id?: string }; const { follower_id, following_id } = (req.body || {}) as {
follower_id?: string;
following_id?: string;
};
if (!follower_id || !following_id) if (!follower_id || !following_id)
return res.status(400).json({ error: "follower_id and following_id required" }); return res
.status(400)
.json({ error: "follower_id and following_id required" });
try { try {
await adminSupabase await adminSupabase
.from("user_follows") .from("user_follows")
.upsert({ follower_id, following_id } as any, { onConflict: "follower_id,following_id" as any }); .upsert({ follower_id, following_id } as any, {
await accrue(follower_id, "loyalty", 5, "follow_user", { following_id }); onConflict: "follower_id,following_id" as any,
});
await accrue(follower_id, "loyalty", 5, "follow_user", {
following_id,
});
const { data: follower } = await adminSupabase const { data: follower } = await adminSupabase
.from("user_profiles") .from("user_profiles")
.select("full_name, username") .select("full_name, username")
.eq("id", follower_id) .eq("id", follower_id)
.maybeSingle(); .maybeSingle();
const followerName = (follower as any)?.full_name || (follower as any)?.username || "Someone"; const followerName =
(follower as any)?.full_name ||
(follower as any)?.username ||
"Someone";
await adminSupabase.from("notifications").insert({ await adminSupabase.from("notifications").insert({
user_id: following_id, user_id: following_id,
type: "info", type: "info",
@ -855,9 +873,14 @@ export function createServer() {
}); });
app.post("/api/social/unfollow", async (req, res) => { app.post("/api/social/unfollow", async (req, res) => {
const { follower_id, following_id } = (req.body || {}) as { follower_id?: string; following_id?: string }; const { follower_id, following_id } = (req.body || {}) as {
follower_id?: string;
following_id?: string;
};
if (!follower_id || !following_id) if (!follower_id || !following_id)
return res.status(400).json({ error: "follower_id and following_id required" }); return res
.status(400)
.json({ error: "follower_id and following_id required" });
try { try {
await adminSupabase await adminSupabase
.from("user_follows") .from("user_follows")
@ -872,18 +895,32 @@ export function createServer() {
// Endorse with notification // Endorse with notification
app.post("/api/social/endorse", async (req, res) => { app.post("/api/social/endorse", async (req, res) => {
const { endorser_id, endorsed_id, skill } = (req.body || {}) as { endorser_id?: string; endorsed_id?: string; skill?: string }; const { endorser_id, endorsed_id, skill } = (req.body || {}) as {
endorser_id?: string;
endorsed_id?: string;
skill?: string;
};
if (!endorser_id || !endorsed_id || !skill) if (!endorser_id || !endorsed_id || !skill)
return res.status(400).json({ error: "endorser_id, endorsed_id, skill required" }); return res
.status(400)
.json({ error: "endorser_id, endorsed_id, skill required" });
try { try {
await adminSupabase.from("endorsements").insert({ endorser_id, endorsed_id, skill } as any); await adminSupabase
await accrue(endorsed_id, "reputation", 2, "endorsement_received", { skill, from: endorser_id }); .from("endorsements")
.insert({ endorser_id, endorsed_id, skill } as any);
await accrue(endorsed_id, "reputation", 2, "endorsement_received", {
skill,
from: endorser_id,
});
const { data: endorser } = await adminSupabase const { data: endorser } = await adminSupabase
.from("user_profiles") .from("user_profiles")
.select("full_name, username") .select("full_name, username")
.eq("id", endorser_id) .eq("id", endorser_id)
.maybeSingle(); .maybeSingle();
const endorserName = (endorser as any)?.full_name || (endorser as any)?.username || "Someone"; const endorserName =
(endorser as any)?.full_name ||
(endorser as any)?.username ||
"Someone";
await adminSupabase.from("notifications").insert({ await adminSupabase.from("notifications").insert({
user_id: endorsed_id, user_id: endorsed_id,
type: "success", type: "success",
@ -909,24 +946,48 @@ export function createServer() {
metadata, metadata,
} = (req.body || {}) as any; } = (req.body || {}) as any;
if (!actor_id || !verb || !object_type) { if (!actor_id || !verb || !object_type) {
return res.status(400).json({ error: "actor_id, verb, object_type required" }); return res
.status(400)
.json({ error: "actor_id, verb, object_type required" });
} }
try { try {
const { data: eventRow, error: evErr } = await adminSupabase const { data: eventRow, error: evErr } = await adminSupabase
.from("activity_events") .from("activity_events")
.insert({ actor_id, verb, object_type, object_id: object_id || null, target_id: target_team_id || target_project_id || null, metadata: metadata || null } as any) .insert({
actor_id,
verb,
object_type,
object_id: object_id || null,
target_id: target_team_id || target_project_id || null,
metadata: metadata || null,
} as any)
.select() .select()
.single(); .single();
if (evErr) return res.status(500).json({ error: evErr.message }); if (evErr) return res.status(500).json({ error: evErr.message });
const notify = async (userId: string, title: string, message?: string) => { const notify = async (
await adminSupabase.from("notifications").insert({ user_id: userId, type: "info", title, message: message || null }); userId: string,
title: string,
message?: string,
) => {
await adminSupabase
.from("notifications")
.insert({
user_id: userId,
type: "info",
title,
message: message || null,
});
}; };
// Notify explicit targets // Notify explicit targets
if (Array.isArray(target_user_ids) && target_user_ids.length) { if (Array.isArray(target_user_ids) && target_user_ids.length) {
for (const uid of target_user_ids) { for (const uid of target_user_ids) {
await notify(uid, `${verb} · ${object_type}`, (metadata && metadata.summary) || null); await notify(
uid,
`${verb} · ${object_type}`,
(metadata && metadata.summary) || null,
);
} }
} }
@ -936,8 +997,12 @@ export function createServer() {
.from("team_memberships") .from("team_memberships")
.select("user_id") .select("user_id")
.eq("team_id", target_team_id); .eq("team_id", target_team_id);
for (const m of (members || [])) { for (const m of members || []) {
await notify((m as any).user_id, `${verb} · ${object_type}`, (metadata && metadata.summary) || null); await notify(
(m as any).user_id,
`${verb} · ${object_type}`,
(metadata && metadata.summary) || null,
);
} }
} }
@ -947,8 +1012,12 @@ export function createServer() {
.from("project_members") .from("project_members")
.select("user_id") .select("user_id")
.eq("project_id", target_project_id); .eq("project_id", target_project_id);
for (const m of (members || [])) { for (const m of members || []) {
await notify((m as any).user_id, `${verb} · ${object_type}`, (metadata && metadata.summary) || null); await notify(
(m as any).user_id,
`${verb} · ${object_type}`,
(metadata && metadata.summary) || null,
);
} }
} }