Prettier format pending files
This commit is contained in:
parent
e5a5607728
commit
df2b52af71
9 changed files with 531 additions and 148 deletions
|
|
@ -74,7 +74,10 @@ const App = () => (
|
|||
<Route path="/feed" element={<Feed />} />
|
||||
<Route path="/teams" element={<Teams />} />
|
||||
<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/me" element={<Profile />} />
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
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 MembershipRole = "owner" | "admin" | "member";
|
||||
|
|
@ -11,14 +14,21 @@ export const aethexCollabService = {
|
|||
async listMyTeams(userId: string) {
|
||||
const { data, error } = await supabase
|
||||
.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)
|
||||
.order("created_at", { ascending: false });
|
||||
if (error) return [] 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
|
||||
try {
|
||||
await aethexUserService.getCurrentUser();
|
||||
|
|
@ -26,7 +36,12 @@ export const aethexCollabService = {
|
|||
|
||||
const { data, error } = await supabase
|
||||
.from("teams")
|
||||
.insert({ owner_id: ownerId, name, description: description || null, visibility })
|
||||
.insert({
|
||||
owner_id: ownerId,
|
||||
name,
|
||||
description: description || null,
|
||||
visibility,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw new Error(error.message || "Unable to create team");
|
||||
|
|
@ -52,7 +67,11 @@ export const aethexCollabService = {
|
|||
return team;
|
||||
},
|
||||
|
||||
async addTeamMember(teamId: string, userId: string, role: MembershipRole = "member") {
|
||||
async addTeamMember(
|
||||
teamId: string,
|
||||
userId: string,
|
||||
role: MembershipRole = "member",
|
||||
) {
|
||||
const { error } = await supabase
|
||||
.from("team_memberships")
|
||||
.insert({ team_id: teamId, user_id: userId, role });
|
||||
|
|
@ -60,7 +79,11 @@ export const aethexCollabService = {
|
|||
},
|
||||
|
||||
// Projects
|
||||
async addProjectMember(projectId: string, userId: string, role: ProjectRole = "contributor") {
|
||||
async addProjectMember(
|
||||
projectId: string,
|
||||
userId: string,
|
||||
role: ProjectRole = "contributor",
|
||||
) {
|
||||
const { error } = await supabase
|
||||
.from("project_members")
|
||||
.insert({ project_id: projectId, user_id: userId, role });
|
||||
|
|
@ -70,7 +93,9 @@ export const aethexCollabService = {
|
|||
async listProjectMembers(projectId: string) {
|
||||
const { data, error } = await supabase
|
||||
.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);
|
||||
if (error) return [] as any[];
|
||||
return (data || []) as any[];
|
||||
|
|
@ -87,10 +112,22 @@ export const aethexCollabService = {
|
|||
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
|
||||
.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()
|
||||
.single();
|
||||
if (error) throw new Error(error.message || "Unable to create task");
|
||||
|
|
|
|||
|
|
@ -56,7 +56,10 @@ export const aethexSocialService = {
|
|||
const resp = await fetch("/api/social/follow", {
|
||||
method: "POST",
|
||||
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());
|
||||
},
|
||||
|
|
@ -65,7 +68,10 @@ export const aethexSocialService = {
|
|||
const resp = await fetch("/api/social/unfollow", {
|
||||
method: "POST",
|
||||
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());
|
||||
},
|
||||
|
|
@ -148,7 +154,11 @@ export const aethexSocialService = {
|
|||
const resp = await fetch("/api/social/endorse", {
|
||||
method: "POST",
|
||||
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());
|
||||
},
|
||||
|
|
|
|||
|
|
@ -509,10 +509,30 @@ export default function Dashboard() {
|
|||
const showProfileSetup = !profileComplete;
|
||||
|
||||
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: "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" },
|
||||
{
|
||||
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: "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) => {
|
||||
|
|
@ -561,10 +581,18 @@ export default function Dashboard() {
|
|||
};
|
||||
|
||||
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: "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) {
|
||||
|
|
@ -1200,7 +1228,12 @@ export default function Dashboard() {
|
|||
Your active development projects
|
||||
</CardDescription>
|
||||
</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
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
|
|
@ -1266,7 +1299,13 @@ export default function Dashboard() {
|
|||
>
|
||||
{getPriorityFromTech(project.technologies || [])}
|
||||
</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
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -1406,14 +1445,27 @@ export default function Dashboard() {
|
|||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{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) => {
|
||||
const team = (t as any).teams || t;
|
||||
return (
|
||||
<div key={team.id} 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
|
||||
key={team.id}
|
||||
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>
|
||||
);
|
||||
})
|
||||
|
|
@ -1428,15 +1480,34 @@ export default function Dashboard() {
|
|||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{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) => (
|
||||
<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="font-medium">{inv.invitee_email}</div>
|
||||
<div className="text-xs text-muted-foreground">{inv.status}</div>
|
||||
<div className="font-medium">
|
||||
{inv.invitee_email}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{inv.status}
|
||||
</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>
|
||||
))
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -6,39 +6,68 @@ export default function Privacy() {
|
|||
<div className="min-h-screen bg-aethex-gradient py-12">
|
||||
<div className="container mx-auto px-4 max-w-4xl space-y-8">
|
||||
<header className="space-y-2">
|
||||
<h1 className="text-3xl font-bold text-gradient-purple">Privacy Policy</h1>
|
||||
<p className="text-sm text-muted-foreground">Effective date: 2025-10-18</p>
|
||||
<h1 className="text-3xl font-bold text-gradient-purple">
|
||||
Privacy Policy
|
||||
</h1>
|
||||
<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").
|
||||
Effective date: 2025-10-18
|
||||
</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>
|
||||
</header>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-semibold">Information We Collect</h2>
|
||||
<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>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>
|
||||
<li>
|
||||
Account data: name, username, email, profile details, social
|
||||
links.
|
||||
</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>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-semibold">How We Use Information</h2>
|
||||
<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>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>
|
||||
<li>
|
||||
Provide and improve the Services, including social, projects,
|
||||
teams, and notifications.
|
||||
</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>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-semibold">Legal Bases (EEA/UK)</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
We process data under: (i) Performance of a contract (providing core features), (ii) Legitimate
|
||||
interests (security, analytics, product improvement), (iii) Consent (where required), and (iv)
|
||||
We process data under: (i) Performance of a contract (providing
|
||||
core features), (ii) Legitimate interests (security, analytics,
|
||||
product improvement), (iii) Consent (where required), and (iv)
|
||||
Compliance with legal obligations.
|
||||
</p>
|
||||
</section>
|
||||
|
|
@ -46,8 +75,9 @@ export default function Privacy() {
|
|||
<section className="space-y-3">
|
||||
<h2 className="font-semibold">Sharing & Service Providers</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
We do not sell your personal information. We use trusted sub-processors to operate the platform:
|
||||
Supabase (auth, database, storage), Vercel/Netlify (hosting/CDN), and Resend (email). These
|
||||
We do not sell your personal information. We use trusted
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
|
|
@ -55,24 +85,31 @@ export default function Privacy() {
|
|||
<section className="space-y-3">
|
||||
<h2 className="font-semibold">International Transfers</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Data may be processed in the United States and other countries. Where applicable, we rely on
|
||||
appropriate safeguards (e.g., SCCs) for cross-border transfers.
|
||||
Data may be processed in the United States and other countries.
|
||||
Where applicable, we rely on appropriate safeguards (e.g., SCCs)
|
||||
for cross-border transfers.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-semibold">Data Retention</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
We retain data for as long as needed to provide Services, comply with law, resolve disputes,
|
||||
and enforce agreements. You may request deletion of your account data, subject to legal holds.
|
||||
We retain data for as long as needed to provide Services, comply
|
||||
with law, resolve disputes, and enforce agreements. You may
|
||||
request deletion of your account data, subject to legal holds.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-semibold">Your Rights</h2>
|
||||
<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>Object to or restrict certain processing; withdraw consent where applicable.</li>
|
||||
<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>
|
||||
</ul>
|
||||
</section>
|
||||
|
|
@ -80,31 +117,35 @@ export default function Privacy() {
|
|||
<section className="space-y-3">
|
||||
<h2 className="font-semibold">Security</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
We use industry-standard measures to protect data in transit and at rest. No method of
|
||||
transmission or storage is 100% secure; you are responsible for safeguarding credentials.
|
||||
We use industry-standard measures to protect data in transit and
|
||||
at rest. No method of transmission or storage is 100% secure; you
|
||||
are responsible for safeguarding credentials.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-semibold">Children</h2>
|
||||
<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
|
||||
knowingly collect data from children. If you believe a child has provided data, contact us.
|
||||
Our Services are not directed to children under 13 (or as defined
|
||||
by local law). We do not knowingly collect data from children. If
|
||||
you believe a child has provided data, contact us.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-semibold">Changes</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
We may update this Policy. Material changes will be announced via the app or email. Your
|
||||
continued use after changes constitutes acceptance.
|
||||
We may update this Policy. Material changes will be announced via
|
||||
the app or email. Your continued use after changes constitutes
|
||||
acceptance.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-semibold">Contact</h2>
|
||||
<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>
|
||||
</section>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,13 @@ import Layout from "@/components/Layout";
|
|||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
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 { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
|
@ -10,7 +16,11 @@ import { Badge } from "@/components/ui/badge";
|
|||
import { aethexCollabService } from "@/lib/aethex-collab-service";
|
||||
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: "doing", title: "In progress", hint: "Active" },
|
||||
{ key: "done", title: "Done", hint: "Completed" },
|
||||
|
|
@ -47,7 +57,12 @@ export default function ProjectBoard() {
|
|||
}, [projectId]);
|
||||
|
||||
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) {
|
||||
map[t.status || "todo"].push(t);
|
||||
}
|
||||
|
|
@ -59,7 +74,13 @@ export default function ProjectBoard() {
|
|||
if (!title.trim()) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
await aethexCollabService.createTask(projectId, title.trim(), description.trim() || null, null, null);
|
||||
await aethexCollabService.createTask(
|
||||
projectId,
|
||||
title.trim(),
|
||||
description.trim() || null,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
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 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;
|
||||
|
||||
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="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">
|
||||
<h1 className="text-3xl font-semibold text-foreground">Project Board</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Track tasks by status. Drag-and-drop coming next.</p>
|
||||
<h1 className="text-3xl font-semibold text-foreground">
|
||||
Project Board
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Track tasks by status. Drag-and-drop coming next.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)]">
|
||||
{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>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
{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>
|
||||
<CardDescription>{col.hint}</CardDescription>
|
||||
</CardHeader>
|
||||
|
|
@ -100,14 +139,26 @@ export default function ProjectBoard() {
|
|||
<p className="text-sm text-muted-foreground">No tasks.</p>
|
||||
) : (
|
||||
grouped[col.key].map((t) => (
|
||||
<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>
|
||||
<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 ? (
|
||||
<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}
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{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}
|
||||
</Button>
|
||||
))}
|
||||
|
|
@ -123,13 +174,27 @@ export default function ProjectBoard() {
|
|||
<Card className="rounded-3xl border-border/40 bg-background/70 shadow-xl backdrop-blur-lg">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Add task</CardTitle>
|
||||
<CardDescription>Keep titles concise; details optional.</CardDescription>
|
||||
<CardDescription>
|
||||
Keep titles concise; details optional.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Input placeholder="Task title" value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
<Textarea placeholder="Description (optional)" value={description} onChange={(e) => setDescription(e.target.value)} />
|
||||
<Input
|
||||
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">
|
||||
<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"}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,13 @@ import Layout from "@/components/Layout";
|
|||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
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 { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
|
@ -39,31 +45,57 @@ export default function Teams() {
|
|||
load();
|
||||
}, [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 () => {
|
||||
if (!user?.id) return;
|
||||
if (!canCreate) return;
|
||||
setCreating(true);
|
||||
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]);
|
||||
setName("");
|
||||
setDescription("");
|
||||
let created: any | null = null;
|
||||
try {
|
||||
created = await aethexCollabService.createTeam(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)));
|
||||
created = await aethexCollabService.createTeam(
|
||||
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" });
|
||||
} catch (e: any) {
|
||||
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 {
|
||||
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;
|
||||
|
||||
|
|
@ -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">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]">
|
||||
|
|
@ -85,25 +119,47 @@ export default function Teams() {
|
|||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{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) => {
|
||||
const team = (t as any).teams || t;
|
||||
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">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarImage src={undefined} />
|
||||
<AvatarFallback>{team.name?.[0]?.toUpperCase() || "T"}</AvatarFallback>
|
||||
<AvatarFallback>
|
||||
{team.name?.[0]?.toUpperCase() || "T"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className="font-medium text-foreground">{team.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{team.visibility || "private"}</div>
|
||||
<div className="font-medium text-foreground">
|
||||
{team.name}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{team.visibility || "private"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="border-border/50">Team</Badge>
|
||||
<Button variant="outline" size="sm" onClick={() => navigate(`/projects/new`)}>New project</Button>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-border/50"
|
||||
>
|
||||
Team
|
||||
</Badge>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/projects/new`)}
|
||||
>
|
||||
New project
|
||||
</Button>
|
||||
</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">
|
||||
<CardHeader>
|
||||
<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>
|
||||
<CardContent className="space-y-3">
|
||||
<Input placeholder="Team name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<Textarea placeholder="Short description (optional)" value={description} onChange={(e) => setDescription(e.target.value)} />
|
||||
<Input
|
||||
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">
|
||||
<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"}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,29 +6,36 @@ export default function Terms() {
|
|||
<div className="min-h-screen bg-aethex-gradient py-12">
|
||||
<div className="container mx-auto px-4 max-w-4xl space-y-8">
|
||||
<header className="space-y-2">
|
||||
<h1 className="text-3xl font-bold text-gradient-purple">Terms of Service</h1>
|
||||
<p className="text-sm text-muted-foreground">Effective date: 2025-10-18</p>
|
||||
<h1 className="text-3xl font-bold text-gradient-purple">
|
||||
Terms of Service
|
||||
</h1>
|
||||
<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.
|
||||
Effective date: 2025-10-18
|
||||
</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>
|
||||
</header>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-semibold">Accounts & Eligibility</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You must be at least 13 years old (or the age required by your jurisdiction). You are
|
||||
responsible for maintaining the confidentiality of your credentials and for all activity
|
||||
under your account.
|
||||
You must be at least 13 years old (or the age required by your
|
||||
jurisdiction). You are responsible for maintaining the
|
||||
confidentiality of your credentials and for all activity under
|
||||
your account.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-semibold">User Content & License</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You retain ownership of content you submit. You grant AeThex a worldwide, non-exclusive,
|
||||
royalty-free license to host, store, reproduce, and display your content solely to operate
|
||||
and improve the Services. You represent you have the rights to submit such content.
|
||||
You retain ownership of content you submit. You grant AeThex a
|
||||
worldwide, non-exclusive, royalty-free license to host, store,
|
||||
reproduce, and display your content solely to operate and improve
|
||||
the Services. You represent you have the rights to submit such
|
||||
content.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
|
@ -38,31 +45,37 @@ export default function Terms() {
|
|||
<li>No unlawful, infringing, or deceptive activity.</li>
|
||||
<li>No harassment, hate, or exploitation.</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>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-semibold">Projects, Teams, and Matching</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Collaboration features (projects, teams, tasks, endorsements) are provided as-is. Team owners
|
||||
and project owners are responsible for membership, roles, and shared materials.
|
||||
Collaboration features (projects, teams, tasks, endorsements) are
|
||||
provided as-is. Team owners and project owners are responsible for
|
||||
membership, roles, and shared materials.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-semibold">Third-Party Services</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
We may integrate third-party services (e.g., Supabase, Resend, hosting/CDN). Your use of
|
||||
those services is subject to their terms and policies.
|
||||
We may integrate third-party services (e.g., Supabase, Resend,
|
||||
hosting/CDN). Your use of those services is subject to their terms
|
||||
and policies.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-semibold">Intellectual Property</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
AeThex and its licensors retain all rights to the platform, branding, and software. You may
|
||||
not copy, modify, or distribute platform code or assets except as permitted by law or written
|
||||
AeThex and its licensors retain all rights to the platform,
|
||||
branding, and software. You may not copy, modify, or distribute
|
||||
platform code or assets except as permitted by law or written
|
||||
authorization.
|
||||
</p>
|
||||
</section>
|
||||
|
|
@ -70,16 +83,19 @@ export default function Terms() {
|
|||
<section className="space-y-3">
|
||||
<h2 className="font-semibold">Termination</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
We may suspend or terminate your access for violations or risk to the platform. You may
|
||||
discontinue use at any time.
|
||||
We may suspend or terminate your access for violations or risk to
|
||||
the platform. You may discontinue use at any time.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<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">
|
||||
The Services are provided “as-is” without warranties. To the maximum extent permitted by law,
|
||||
AeThex will not be liable for indirect, incidental, or consequential damages arising from or
|
||||
The Services are provided “as-is” without warranties. To the
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
|
|
@ -87,24 +103,25 @@ export default function Terms() {
|
|||
<section className="space-y-3">
|
||||
<h2 className="font-semibold">Indemnification</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You agree to indemnify and hold AeThex harmless from claims arising out of your content or
|
||||
misuse of the Services.
|
||||
You agree to indemnify and hold AeThex harmless from claims
|
||||
arising out of your content or misuse of the Services.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-semibold">Changes to Terms</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
We may update these Terms. Material changes will be announced via the app or email. Your
|
||||
continued use constitutes acceptance of updated Terms.
|
||||
We may update these Terms. Material changes will be announced via
|
||||
the app or email. Your continued use constitutes acceptance of
|
||||
updated Terms.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-semibold">Governing Law & Contact</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
These Terms are governed by applicable laws of the United States. For questions, contact
|
||||
legal@aethex.biz.
|
||||
These Terms are governed by applicable laws of the United States.
|
||||
For questions, contact legal@aethex.biz.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
|
|
|||
117
server/index.ts
117
server/index.ts
|
|
@ -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 {
|
||||
await adminSupabase.from("notifications").insert({
|
||||
user_id: inviter_id,
|
||||
|
|
@ -799,7 +801,9 @@ export function createServer() {
|
|||
if (inviterId) {
|
||||
await accrue(inviterId, "xp", 100, "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 {
|
||||
await adminSupabase.from("notifications").insert({
|
||||
user_id: inviterId,
|
||||
|
|
@ -810,7 +814,9 @@ export function createServer() {
|
|||
} catch {}
|
||||
}
|
||||
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 {
|
||||
await adminSupabase.from("notifications").insert({
|
||||
user_id: acceptor_id,
|
||||
|
|
@ -828,20 +834,32 @@ export function createServer() {
|
|||
|
||||
// Follow/unfollow with notifications
|
||||
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)
|
||||
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 {
|
||||
await adminSupabase
|
||||
.from("user_follows")
|
||||
.upsert({ follower_id, following_id } as any, { onConflict: "follower_id,following_id" as any });
|
||||
await accrue(follower_id, "loyalty", 5, "follow_user", { following_id });
|
||||
.upsert({ follower_id, following_id } as any, {
|
||||
onConflict: "follower_id,following_id" as any,
|
||||
});
|
||||
await accrue(follower_id, "loyalty", 5, "follow_user", {
|
||||
following_id,
|
||||
});
|
||||
const { data: follower } = await adminSupabase
|
||||
.from("user_profiles")
|
||||
.select("full_name, username")
|
||||
.eq("id", follower_id)
|
||||
.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({
|
||||
user_id: following_id,
|
||||
type: "info",
|
||||
|
|
@ -855,9 +873,14 @@ export function createServer() {
|
|||
});
|
||||
|
||||
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)
|
||||
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 {
|
||||
await adminSupabase
|
||||
.from("user_follows")
|
||||
|
|
@ -872,18 +895,32 @@ export function createServer() {
|
|||
|
||||
// Endorse with notification
|
||||
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)
|
||||
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 {
|
||||
await adminSupabase.from("endorsements").insert({ endorser_id, endorsed_id, skill } as any);
|
||||
await accrue(endorsed_id, "reputation", 2, "endorsement_received", { skill, from: endorser_id });
|
||||
await adminSupabase
|
||||
.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
|
||||
.from("user_profiles")
|
||||
.select("full_name, username")
|
||||
.eq("id", endorser_id)
|
||||
.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({
|
||||
user_id: endorsed_id,
|
||||
type: "success",
|
||||
|
|
@ -909,24 +946,48 @@ export function createServer() {
|
|||
metadata,
|
||||
} = (req.body || {}) as any;
|
||||
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 {
|
||||
const { data: eventRow, error: evErr } = await adminSupabase
|
||||
.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()
|
||||
.single();
|
||||
if (evErr) return res.status(500).json({ error: evErr.message });
|
||||
|
||||
const notify = async (userId: string, title: string, message?: string) => {
|
||||
await adminSupabase.from("notifications").insert({ user_id: userId, type: "info", title, message: message || null });
|
||||
const notify = async (
|
||||
userId: string,
|
||||
title: string,
|
||||
message?: string,
|
||||
) => {
|
||||
await adminSupabase
|
||||
.from("notifications")
|
||||
.insert({
|
||||
user_id: userId,
|
||||
type: "info",
|
||||
title,
|
||||
message: message || null,
|
||||
});
|
||||
};
|
||||
|
||||
// Notify explicit targets
|
||||
if (Array.isArray(target_user_ids) && target_user_ids.length) {
|
||||
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")
|
||||
.select("user_id")
|
||||
.eq("team_id", target_team_id);
|
||||
for (const m of (members || [])) {
|
||||
await notify((m as any).user_id, `${verb} · ${object_type}`, (metadata && metadata.summary) || null);
|
||||
for (const m of members || []) {
|
||||
await notify(
|
||||
(m as any).user_id,
|
||||
`${verb} · ${object_type}`,
|
||||
(metadata && metadata.summary) || null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -947,8 +1012,12 @@ export function createServer() {
|
|||
.from("project_members")
|
||||
.select("user_id")
|
||||
.eq("project_id", target_project_id);
|
||||
for (const m of (members || [])) {
|
||||
await notify((m as any).user_id, `${verb} · ${object_type}`, (metadata && metadata.summary) || null);
|
||||
for (const m of members || []) {
|
||||
await notify(
|
||||
(m as any).user_id,
|
||||
`${verb} · ${object_type}`,
|
||||
(metadata && metadata.summary) || null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue