feat: implement friendship system with requests and management features
All checks were successful
Build Docker Image / build (push) Successful in 1m15s

- Added a new friendship table schema to manage friend requests and relationships.
- Updated profile page to include tabs for managing friends, incoming requests, and outgoing requests.
- Implemented functionality to send, accept, decline, cancel, and remove friend requests.
- Enhanced user experience with feedback messages for friend request actions.
This commit is contained in:
2026-03-06 19:28:42 +01:00
parent 5cd989b098
commit f35f4565b6
6 changed files with 1714 additions and 5 deletions

View File

@@ -0,0 +1,12 @@
CREATE TABLE `friendship` (
`id` text PRIMARY KEY NOT NULL,
`requesterId` text NOT NULL,
`addresseeId` text NOT NULL,
`status` text DEFAULT 'pending' NOT NULL,
`createdAt` integer NOT NULL,
`updatedAt` integer NOT NULL,
FOREIGN KEY (`requesterId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`addresseeId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `friendship_requesterId_addresseeId_unique` ON `friendship` (`requesterId`,`addresseeId`);

File diff suppressed because it is too large Load Diff

View File

@@ -57,6 +57,13 @@
"when": 1772735982970, "when": 1772735982970,
"tag": "0007_gray_shinko_yamashiro", "tag": "0007_gray_shinko_yamashiro",
"breakpoints": true "breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1772821532270,
"tag": "0008_skinny_warpath",
"breakpoints": true
} }
] ]
} }

View File

@@ -5,6 +5,7 @@ import { user } from './auth.schema';
export type DevilFruitType = 'Paramecia' | 'Zoan' | 'Logia' | 'Smile' | 'Unknown'; export type DevilFruitType = 'Paramecia' | 'Zoan' | 'Logia' | 'Smile' | 'Unknown';
export type Status = 'Alive' | 'Dead' | 'Unknown'; export type Status = 'Alive' | 'Dead' | 'Unknown';
export type FriendshipStatus = 'pending' | 'accepted' | 'declined';
// Define the site config table schema // Define the site config table schema
export const config = sqliteTable('config', { export const config = sqliteTable('config', {
@@ -122,5 +123,23 @@ export const userCharacterHistory = sqliteTable('userCharacterHistory', {
unique().on(table.userId, table.characterHistoryId) unique().on(table.userId, table.characterHistoryId)
]); ]);
// Define the friendship table schema (friend requests + accepted friends)
export const friendship = sqliteTable('friendship', {
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
requesterId: text('requesterId')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
addresseeId: text('addresseeId')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
status: text('status').$type<FriendshipStatus>().notNull().default('pending'),
createdAt: integer('createdAt').notNull().$default(() => Date.now()),
updatedAt: integer('updatedAt').notNull().$default(() => Date.now()),
}, (table) => [
unique().on(table.requesterId, table.addresseeId)
]);
export * from './auth.schema'; export * from './auth.schema';

View File

@@ -2,8 +2,8 @@ import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { auth } from '$lib/server/auth'; import { auth } from '$lib/server/auth';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { session, userCharacterHistory, characterHistory, character } from '$lib/server/db/schema'; import { session, userCharacterHistory, characterHistory, character, friendship, user } from '$lib/server/db/schema';
import { eq, desc } from 'drizzle-orm'; import { and, desc, eq, or } from 'drizzle-orm';
import { APIError } from 'better-auth/api'; import { APIError } from 'better-auth/api';
export const load: PageServerLoad = async (event) => { export const load: PageServerLoad = async (event) => {
@@ -11,6 +11,8 @@ export const load: PageServerLoad = async (event) => {
return redirect(302, '/login'); return redirect(302, '/login');
} }
const currentUserId = event.locals.user.id;
// Fetch all sessions for this user // Fetch all sessions for this user
const userSessions = await db const userSessions = await db
.select() .select()
@@ -34,10 +36,69 @@ export const load: PageServerLoad = async (event) => {
.where(eq(userCharacterHistory.userId, event.locals.user.id)) .where(eq(userCharacterHistory.userId, event.locals.user.id))
.orderBy(desc(characterHistory.date)); .orderBy(desc(characterHistory.date));
const incomingRequests = await db
.select({
id: friendship.id,
createdAt: friendship.createdAt,
requesterId: friendship.requesterId,
requesterName: user.name,
requesterEmail: user.email,
requesterImage: user.image
})
.from(friendship)
.innerJoin(user, eq(friendship.requesterId, user.id))
.where(and(eq(friendship.addresseeId, currentUserId), eq(friendship.status, 'pending')))
.orderBy(desc(friendship.createdAt));
const outgoingRequests = await db
.select({
id: friendship.id,
createdAt: friendship.createdAt,
addresseeId: friendship.addresseeId,
addresseeName: user.name,
addresseeEmail: user.email,
addresseeImage: user.image
})
.from(friendship)
.innerJoin(user, eq(friendship.addresseeId, user.id))
.where(and(eq(friendship.requesterId, currentUserId), eq(friendship.status, 'pending')))
.orderBy(desc(friendship.createdAt));
const acceptedAsRequester = await db
.select({
id: friendship.id,
createdAt: friendship.createdAt,
friendId: friendship.addresseeId,
friendName: user.name,
friendEmail: user.email,
friendImage: user.image
})
.from(friendship)
.innerJoin(user, eq(friendship.addresseeId, user.id))
.where(and(eq(friendship.requesterId, currentUserId), eq(friendship.status, 'accepted')));
const acceptedAsAddressee = await db
.select({
id: friendship.id,
createdAt: friendship.createdAt,
friendId: friendship.requesterId,
friendName: user.name,
friendEmail: user.email,
friendImage: user.image
})
.from(friendship)
.innerJoin(user, eq(friendship.requesterId, user.id))
.where(and(eq(friendship.addresseeId, currentUserId), eq(friendship.status, 'accepted')));
const friends = [...acceptedAsRequester, ...acceptedAsAddressee].sort((a, b) => b.createdAt - a.createdAt);
return { return {
user: event.locals.user, user: event.locals.user,
sessions: userSessions, sessions: userSessions,
dailyHistory: dailyHistory dailyHistory: dailyHistory,
incomingRequests,
outgoingRequests,
friends
}; };
}; };
@@ -133,5 +194,207 @@ export const actions: Actions = {
} }
return { success: true, message: 'Session révoquée avec succès' }; return { success: true, message: 'Session révoquée avec succès' };
},
sendFriendRequest: async (event) => {
if (!event.locals.user) {
return redirect(302, '/login');
}
const formData = await event.request.formData();
const friendEmail = formData.get('friendEmail')?.toString().trim().toLowerCase() ?? '';
if (!friendEmail) {
return fail(400, { message: 'Email requis pour envoyer une demande' });
}
const me = event.locals.user;
if (friendEmail === me.email?.toLowerCase()) {
return fail(400, { message: 'Tu ne peux pas t\'ajouter toi-même' });
}
const [targetUser] = await db
.select({ id: user.id, email: user.email })
.from(user)
.where(eq(user.email, friendEmail))
.limit(1);
if (!targetUser) {
return fail(404, { message: 'Aucun utilisateur trouvé avec cet email' });
}
const [existing] = await db
.select()
.from(friendship)
.where(
or(
and(eq(friendship.requesterId, me.id), eq(friendship.addresseeId, targetUser.id)),
and(eq(friendship.requesterId, targetUser.id), eq(friendship.addresseeId, me.id))
)
)
.limit(1);
const now = Date.now();
if (!existing) {
await db.insert(friendship).values({
requesterId: me.id,
addresseeId: targetUser.id,
status: 'pending',
createdAt: now,
updatedAt: now
});
return { success: true, message: 'Demande d\'ami envoyée' };
}
if (existing.status === 'accepted') {
return fail(400, { message: 'Vous êtes déjà amis' });
}
if (existing.status === 'pending') {
if (existing.requesterId === targetUser.id && existing.addresseeId === me.id) {
await db
.update(friendship)
.set({
status: 'accepted',
updatedAt: now
})
.where(eq(friendship.id, existing.id));
return { success: true, message: 'Demande acceptée automatiquement' };
}
return fail(400, { message: 'Demande déjà envoyée' });
}
await db
.update(friendship)
.set({
requesterId: me.id,
addresseeId: targetUser.id,
status: 'pending',
updatedAt: now
})
.where(eq(friendship.id, existing.id));
return { success: true, message: 'Demande d\'ami envoyée' };
},
acceptFriendRequest: async (event) => {
if (!event.locals.user) {
return redirect(302, '/login');
}
const formData = await event.request.formData();
const friendshipId = formData.get('friendshipId')?.toString() ?? '';
if (!friendshipId) {
return fail(400, { message: 'Demande invalide' });
}
const now = Date.now();
const result = await db
.update(friendship)
.set({ status: 'accepted', updatedAt: now })
.where(
and(
eq(friendship.id, friendshipId),
eq(friendship.addresseeId, event.locals.user.id),
eq(friendship.status, 'pending')
)
)
.returning({ id: friendship.id });
if (result.length === 0) {
return fail(404, { message: 'Demande introuvable' });
}
return { success: true, message: 'Demande acceptée' };
},
declineFriendRequest: async (event) => {
if (!event.locals.user) {
return redirect(302, '/login');
}
const formData = await event.request.formData();
const friendshipId = formData.get('friendshipId')?.toString() ?? '';
if (!friendshipId) {
return fail(400, { message: 'Demande invalide' });
}
const now = Date.now();
const result = await db
.update(friendship)
.set({ status: 'declined', updatedAt: now })
.where(
and(
eq(friendship.id, friendshipId),
eq(friendship.addresseeId, event.locals.user.id),
eq(friendship.status, 'pending')
)
)
.returning({ id: friendship.id });
if (result.length === 0) {
return fail(404, { message: 'Demande introuvable' });
}
return { success: true, message: 'Demande refusée' };
},
cancelFriendRequest: async (event) => {
if (!event.locals.user) {
return redirect(302, '/login');
}
const formData = await event.request.formData();
const friendshipId = formData.get('friendshipId')?.toString() ?? '';
if (!friendshipId) {
return fail(400, { message: 'Demande invalide' });
}
const result = await db
.delete(friendship)
.where(
and(
eq(friendship.id, friendshipId),
eq(friendship.requesterId, event.locals.user.id),
eq(friendship.status, 'pending')
)
)
.returning({ id: friendship.id });
if (result.length === 0) {
return fail(404, { message: 'Demande introuvable' });
}
return { success: true, message: 'Demande annulée' };
},
removeFriend: async (event) => {
if (!event.locals.user) {
return redirect(302, '/login');
}
const formData = await event.request.formData();
const friendshipId = formData.get('friendshipId')?.toString() ?? '';
if (!friendshipId) {
return fail(400, { message: 'Relation invalide' });
}
const result = await db
.delete(friendship)
.where(
and(
eq(friendship.id, friendshipId),
eq(friendship.status, 'accepted'),
or(eq(friendship.requesterId, event.locals.user.id), eq(friendship.addresseeId, event.locals.user.id))
)
)
.returning({ id: friendship.id });
if (result.length === 0) {
return fail(404, { message: 'Relation introuvable' });
}
return { success: true, message: 'Ami supprimé' };
} }
}; };

View File

@@ -10,14 +10,18 @@
let { data, form }: Props = $props(); let { data, form }: Props = $props();
let isLoading = $state(false); let isLoading = $state(false);
let activeTab = $state<'profile' | 'password' | 'sessions' | 'daily'>('profile'); let activeTab = $state<'profile' | 'password' | 'sessions' | 'daily' | 'friends'>('profile');
let name = $state(''); let name = $state('');
let friendEmail = $state('');
let showSuccess = $state(false); let showSuccess = $state(false);
let oldPassword = $state(''); let oldPassword = $state('');
let newPassword = $state(''); let newPassword = $state('');
let confirmPassword = $state(''); let confirmPassword = $state('');
let sessions = $state<any[]>([]); let sessions = $state<any[]>([]);
let dailyHistory = $state<any[]>([]); let dailyHistory = $state<any[]>([]);
let friends = $state<any[]>([]);
let incomingRequests = $state<any[]>([]);
let outgoingRequests = $state<any[]>([]);
let tabsElement: HTMLDivElement | undefined; let tabsElement: HTMLDivElement | undefined;
$effect(() => { $effect(() => {
@@ -32,6 +36,18 @@
dailyHistory = (data as any).dailyHistory || []; dailyHistory = (data as any).dailyHistory || [];
}); });
$effect(() => {
friends = (data as any).friends || [];
});
$effect(() => {
incomingRequests = (data as any).incomingRequests || [];
});
$effect(() => {
outgoingRequests = (data as any).outgoingRequests || [];
});
$effect(() => { $effect(() => {
if (form && form.success === true) { if (form && form.success === true) {
showSuccess = true; showSuccess = true;
@@ -41,7 +57,7 @@
} }
}); });
const handleTabChange = (tab: 'profile' | 'password' | 'sessions' | 'daily') => { const handleTabChange = (tab: 'profile' | 'password' | 'sessions' | 'daily' | 'friends') => {
activeTab = tab; activeTab = tab;
}; };
@@ -104,6 +120,14 @@
> >
Sessions Sessions
</button> </button>
<button
onclick={() => handleTabChange('friends')}
class="px-4 py-3 font-semibold uppercase tracking-[0.1em] transition {activeTab === 'friends'
? 'border-b-2 border-amber-300 text-amber-100'
: 'text-slate-400 hover:text-slate-100'}"
>
Amis
</button>
</div> </div>
<!-- Tab Content --> <!-- Tab Content -->
@@ -184,6 +208,128 @@
</div> </div>
{/if} {/if}
<!-- Friends Tab -->
{#if activeTab === 'friends'}
<div class="rounded-3xl border border-white/10 bg-white/5 p-6 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur sm:p-8">
<h2 class="mb-6 text-2xl font-bold uppercase tracking-[0.2em] text-amber-50">
Système d'amis
</h2>
<form
method="POST"
action="?/sendFriendRequest"
use:enhance={() => {
isLoading = true;
return async ({ update }) => {
isLoading = false;
friendEmail = '';
await update();
};
}}
class="mb-8 space-y-3"
>
<label for="friendEmail" class="block text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">
Ajouter un ami par email
</label>
<div class="flex gap-2">
<input
id="friendEmail"
type="email"
name="friendEmail"
required
bind:value={friendEmail}
placeholder="ami@email.com"
class="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-white placeholder-slate-500 transition focus:border-amber-300 focus:outline-none focus:ring-2 focus:ring-amber-300/30"
/>
<button
type="submit"
disabled={isLoading}
class="rounded-full bg-amber-300 px-4 py-2 text-sm font-semibold text-slate-900 transition disabled:opacity-50 hover:bg-amber-200"
>
{isLoading ? 'Envoi...' : 'Envoyer'}
</button>
</div>
{#if form?.message}
<p class="text-sm {form.success ? 'text-green-300' : 'text-red-300'}">{form.message}</p>
{/if}
</form>
<div class="space-y-8">
<div>
<h3 class="mb-3 text-sm font-semibold uppercase tracking-[0.15em] text-amber-100">Demandes reçues</h3>
{#if incomingRequests.length === 0}
<p class="text-sm text-slate-400">Aucune demande reçue.</p>
{:else}
<div class="space-y-3">
{#each incomingRequests as req}
<div class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-white/10 bg-white/5 px-4 py-3">
<div>
<p class="font-semibold text-white">{req.requesterName}</p>
<p class="text-xs text-slate-400">{req.requesterEmail}</p>
</div>
<div class="flex gap-2">
<form method="POST" action="?/acceptFriendRequest" use:enhance>
<input type="hidden" name="friendshipId" value={req.id} />
<button type="submit" class="rounded-lg border border-emerald-400/50 bg-emerald-900/20 px-3 py-1.5 text-xs font-semibold text-emerald-300 transition hover:bg-emerald-900/40">Accepter</button>
</form>
<form method="POST" action="?/declineFriendRequest" use:enhance>
<input type="hidden" name="friendshipId" value={req.id} />
<button type="submit" class="rounded-lg border border-red-500/50 bg-red-900/20 px-3 py-1.5 text-xs font-semibold text-red-300 transition hover:bg-red-900/40">Refuser</button>
</form>
</div>
</div>
{/each}
</div>
{/if}
</div>
<div>
<h3 class="mb-3 text-sm font-semibold uppercase tracking-[0.15em] text-amber-100">Demandes envoyées</h3>
{#if outgoingRequests.length === 0}
<p class="text-sm text-slate-400">Aucune demande envoyée.</p>
{:else}
<div class="space-y-3">
{#each outgoingRequests as req}
<div class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-white/10 bg-white/5 px-4 py-3">
<div>
<p class="font-semibold text-white">{req.addresseeName}</p>
<p class="text-xs text-slate-400">{req.addresseeEmail}</p>
</div>
<form method="POST" action="?/cancelFriendRequest" use:enhance>
<input type="hidden" name="friendshipId" value={req.id} />
<button type="submit" class="rounded-lg border border-red-500/50 bg-red-900/20 px-3 py-1.5 text-xs font-semibold text-red-300 transition hover:bg-red-900/40">Annuler</button>
</form>
</div>
{/each}
</div>
{/if}
</div>
<div>
<h3 class="mb-3 text-sm font-semibold uppercase tracking-[0.15em] text-amber-100">Mes amis</h3>
{#if friends.length === 0}
<p class="text-sm text-slate-400">Tu n'as pas encore d'amis.</p>
{:else}
<div class="space-y-3">
{#each friends as friend}
<div class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-white/10 bg-white/5 px-4 py-3">
<div>
<p class="font-semibold text-white">{friend.friendName}</p>
<p class="text-xs text-slate-400">{friend.friendEmail}</p>
</div>
<form method="POST" action="?/removeFriend" use:enhance>
<input type="hidden" name="friendshipId" value={friend.id} />
<button type="submit" class="rounded-lg border border-red-500/50 bg-red-900/20 px-3 py-1.5 text-xs font-semibold text-red-300 transition hover:bg-red-900/40">Supprimer</button>
</form>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
{/if}
<!-- Password Tab --> <!-- Password Tab -->
{#if activeTab === 'password'} {#if activeTab === 'password'}
<div class="rounded-3xl border border-white/10 bg-white/5 p-6 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur sm:p-8"> <div class="rounded-3xl border border-white/10 bg-white/5 p-6 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur sm:p-8">