feat: implement friendship system with requests and management features
All checks were successful
Build Docker Image / build (push) Successful in 1m15s
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:
@@ -5,6 +5,7 @@ import { user } from './auth.schema';
|
||||
export type DevilFruitType = 'Paramecia' | 'Zoan' | 'Logia' | 'Smile' | 'Unknown';
|
||||
|
||||
export type Status = 'Alive' | 'Dead' | 'Unknown';
|
||||
export type FriendshipStatus = 'pending' | 'accepted' | 'declined';
|
||||
|
||||
// Define the site config table schema
|
||||
export const config = sqliteTable('config', {
|
||||
@@ -122,5 +123,23 @@ export const userCharacterHistory = sqliteTable('userCharacterHistory', {
|
||||
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';
|
||||
|
||||
@@ -2,8 +2,8 @@ import { fail, redirect } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { auth } from '$lib/server/auth';
|
||||
import { db } from '$lib/server/db';
|
||||
import { session, userCharacterHistory, characterHistory, character } from '$lib/server/db/schema';
|
||||
import { eq, desc } from 'drizzle-orm';
|
||||
import { session, userCharacterHistory, characterHistory, character, friendship, user } from '$lib/server/db/schema';
|
||||
import { and, desc, eq, or } from 'drizzle-orm';
|
||||
import { APIError } from 'better-auth/api';
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
@@ -11,6 +11,8 @@ export const load: PageServerLoad = async (event) => {
|
||||
return redirect(302, '/login');
|
||||
}
|
||||
|
||||
const currentUserId = event.locals.user.id;
|
||||
|
||||
// Fetch all sessions for this user
|
||||
const userSessions = await db
|
||||
.select()
|
||||
@@ -34,10 +36,69 @@ export const load: PageServerLoad = async (event) => {
|
||||
.where(eq(userCharacterHistory.userId, event.locals.user.id))
|
||||
.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 {
|
||||
user: event.locals.user,
|
||||
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' };
|
||||
},
|
||||
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é' };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -10,14 +10,18 @@
|
||||
let { data, form }: Props = $props();
|
||||
|
||||
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 friendEmail = $state('');
|
||||
let showSuccess = $state(false);
|
||||
let oldPassword = $state('');
|
||||
let newPassword = $state('');
|
||||
let confirmPassword = $state('');
|
||||
let sessions = $state<any[]>([]);
|
||||
let dailyHistory = $state<any[]>([]);
|
||||
let friends = $state<any[]>([]);
|
||||
let incomingRequests = $state<any[]>([]);
|
||||
let outgoingRequests = $state<any[]>([]);
|
||||
let tabsElement: HTMLDivElement | undefined;
|
||||
|
||||
$effect(() => {
|
||||
@@ -32,6 +36,18 @@
|
||||
dailyHistory = (data as any).dailyHistory || [];
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
friends = (data as any).friends || [];
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
incomingRequests = (data as any).incomingRequests || [];
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
outgoingRequests = (data as any).outgoingRequests || [];
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (form && form.success === true) {
|
||||
showSuccess = true;
|
||||
@@ -41,7 +57,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
const handleTabChange = (tab: 'profile' | 'password' | 'sessions' | 'daily') => {
|
||||
const handleTabChange = (tab: 'profile' | 'password' | 'sessions' | 'daily' | 'friends') => {
|
||||
activeTab = tab;
|
||||
};
|
||||
|
||||
@@ -104,6 +120,14 @@
|
||||
>
|
||||
Sessions
|
||||
</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>
|
||||
|
||||
<!-- Tab Content -->
|
||||
@@ -184,6 +208,128 @@
|
||||
</div>
|
||||
{/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 -->
|
||||
{#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">
|
||||
|
||||
Reference in New Issue
Block a user