Add house leaderboard feature: Integrate house leaderboard functionality in LeaderboardPage and LeaderboardSection components. Update userStatsService to fetch house leaderboard data, and enhance UI to display house rankings, scores, and member details. Update Prisma schema to include house-related models and relationships, and seed database with initial house data.
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
This commit is contained in:
@@ -34,3 +34,10 @@ export class ConflictError extends BusinessError {
|
||||
this.name = "ConflictError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ForbiddenError extends BusinessError {
|
||||
constructor(message: string) {
|
||||
super(message, "FORBIDDEN");
|
||||
this.name = "ForbiddenError";
|
||||
}
|
||||
}
|
||||
|
||||
893
services/houses/house.service.ts
Normal file
893
services/houses/house.service.ts
Normal file
@@ -0,0 +1,893 @@
|
||||
import { prisma } from "../database";
|
||||
import type {
|
||||
House,
|
||||
HouseMembership,
|
||||
HouseInvitation,
|
||||
HouseRequest,
|
||||
HouseRole,
|
||||
InvitationStatus,
|
||||
RequestStatus,
|
||||
Prisma,
|
||||
} from "@/prisma/generated/prisma/client";
|
||||
import {
|
||||
ValidationError,
|
||||
NotFoundError,
|
||||
ConflictError,
|
||||
ForbiddenError,
|
||||
} from "../errors";
|
||||
|
||||
const HOUSE_NAME_MIN_LENGTH = 3;
|
||||
const HOUSE_NAME_MAX_LENGTH = 50;
|
||||
const HOUSE_DESCRIPTION_MAX_LENGTH = 500;
|
||||
|
||||
export interface CreateHouseInput {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
creatorId: string;
|
||||
}
|
||||
|
||||
export interface UpdateHouseInput {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface InviteUserInput {
|
||||
houseId: string;
|
||||
inviterId: string;
|
||||
inviteeId: string;
|
||||
}
|
||||
|
||||
export interface RequestToJoinInput {
|
||||
houseId: string;
|
||||
requesterId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service de gestion des maisons
|
||||
*/
|
||||
export class HouseService {
|
||||
/**
|
||||
* Récupère une maison par son ID
|
||||
*/
|
||||
async getHouseById(
|
||||
id: string,
|
||||
include?: Prisma.HouseInclude
|
||||
): Promise<House | null> {
|
||||
return prisma.house.findUnique({
|
||||
where: { id },
|
||||
include,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère toutes les maisons avec pagination
|
||||
*/
|
||||
async getAllHouses(options?: {
|
||||
skip?: number;
|
||||
take?: number;
|
||||
include?: Prisma.HouseInclude;
|
||||
orderBy?: Prisma.HouseOrderByWithRelationInput;
|
||||
}): Promise<House[]> {
|
||||
return prisma.house.findMany({
|
||||
skip: options?.skip,
|
||||
take: options?.take,
|
||||
include: options?.include,
|
||||
orderBy: options?.orderBy || { createdAt: "desc" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Recherche des maisons par nom
|
||||
*/
|
||||
async searchHouses(
|
||||
searchTerm: string,
|
||||
options?: {
|
||||
skip?: number;
|
||||
take?: number;
|
||||
include?: Prisma.HouseInclude;
|
||||
}
|
||||
): Promise<House[]> {
|
||||
return prisma.house.findMany({
|
||||
where: {
|
||||
name: {
|
||||
contains: searchTerm,
|
||||
mode: "insensitive",
|
||||
},
|
||||
},
|
||||
skip: options?.skip,
|
||||
take: options?.take,
|
||||
include: options?.include,
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les maisons d'un utilisateur
|
||||
*/
|
||||
async getUserHouses(
|
||||
userId: string,
|
||||
include?: Prisma.HouseInclude
|
||||
): Promise<House[]> {
|
||||
const memberships = await prisma.houseMembership.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
house: {
|
||||
include: include,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return memberships.map((m) => m.house);
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère la maison d'un utilisateur (s'il en a une)
|
||||
*/
|
||||
async getUserHouse(
|
||||
userId: string,
|
||||
include?: Prisma.HouseInclude
|
||||
): Promise<House | null> {
|
||||
const membership = await prisma.houseMembership.findFirst({
|
||||
where: { userId },
|
||||
include: {
|
||||
house: {
|
||||
include: include,
|
||||
},
|
||||
},
|
||||
orderBy: { joinedAt: "asc" }, // Première maison jointe
|
||||
});
|
||||
|
||||
return membership?.house || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si un utilisateur est membre d'une maison
|
||||
*/
|
||||
async isUserMemberOfHouse(
|
||||
userId: string,
|
||||
houseId: string
|
||||
): Promise<boolean> {
|
||||
const membership = await prisma.houseMembership.findUnique({
|
||||
where: {
|
||||
houseId_userId: {
|
||||
houseId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
return !!membership;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si un utilisateur est propriétaire ou admin d'une maison
|
||||
*/
|
||||
async isUserOwnerOrAdmin(
|
||||
userId: string,
|
||||
houseId: string
|
||||
): Promise<boolean> {
|
||||
const membership = await prisma.houseMembership.findUnique({
|
||||
where: {
|
||||
houseId_userId: {
|
||||
houseId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return membership?.role === "OWNER" || membership?.role === "ADMIN";
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si un utilisateur est propriétaire d'une maison
|
||||
*/
|
||||
async isUserOwner(userId: string, houseId: string): Promise<boolean> {
|
||||
const membership = await prisma.houseMembership.findUnique({
|
||||
where: {
|
||||
houseId_userId: {
|
||||
houseId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return membership?.role === "OWNER";
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère le rôle d'un utilisateur dans une maison
|
||||
*/
|
||||
async getUserRole(
|
||||
userId: string,
|
||||
houseId: string
|
||||
): Promise<HouseRole | null> {
|
||||
const membership = await prisma.houseMembership.findUnique({
|
||||
where: {
|
||||
houseId_userId: {
|
||||
houseId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return membership?.role || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les membres d'une maison
|
||||
*/
|
||||
async getHouseMembers(
|
||||
houseId: string,
|
||||
include?: Prisma.HouseMembershipInclude
|
||||
): Promise<HouseMembership[]> {
|
||||
return prisma.houseMembership.findMany({
|
||||
where: { houseId },
|
||||
include,
|
||||
orderBy: [
|
||||
{ role: "asc" }, // OWNER, ADMIN, MEMBER
|
||||
{ joinedAt: "asc" },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée une nouvelle maison
|
||||
*/
|
||||
async createHouse(data: CreateHouseInput): Promise<House> {
|
||||
// Validation
|
||||
if (!data.name || data.name.trim().length === 0) {
|
||||
throw new ValidationError("Le nom de la maison est requis", "name");
|
||||
}
|
||||
|
||||
if (
|
||||
data.name.length < HOUSE_NAME_MIN_LENGTH ||
|
||||
data.name.length > HOUSE_NAME_MAX_LENGTH
|
||||
) {
|
||||
throw new ValidationError(
|
||||
`Le nom de la maison doit contenir entre ${HOUSE_NAME_MIN_LENGTH} et ${HOUSE_NAME_MAX_LENGTH} caractères`,
|
||||
"name"
|
||||
);
|
||||
}
|
||||
|
||||
if (data.description && data.description.length > HOUSE_DESCRIPTION_MAX_LENGTH) {
|
||||
throw new ValidationError(
|
||||
`La description ne peut pas dépasser ${HOUSE_DESCRIPTION_MAX_LENGTH} caractères`,
|
||||
"description"
|
||||
);
|
||||
}
|
||||
|
||||
// Vérifier si l'utilisateur est déjà dans une maison
|
||||
const existingMembership = await prisma.houseMembership.findFirst({
|
||||
where: { userId: data.creatorId },
|
||||
});
|
||||
|
||||
if (existingMembership) {
|
||||
throw new ConflictError(
|
||||
"Vous êtes déjà membre d'une maison. Vous devez quitter votre maison actuelle avant d'en créer une nouvelle."
|
||||
);
|
||||
}
|
||||
|
||||
// Vérifier si le nom est déjà pris
|
||||
const existingHouse = await prisma.house.findFirst({
|
||||
where: {
|
||||
name: {
|
||||
equals: data.name.trim(),
|
||||
mode: "insensitive",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existingHouse) {
|
||||
throw new ConflictError("Ce nom de maison est déjà utilisé");
|
||||
}
|
||||
|
||||
// Créer la maison et ajouter le créateur comme OWNER
|
||||
return prisma.house.create({
|
||||
data: {
|
||||
name: data.name.trim(),
|
||||
description: data.description?.trim() || null,
|
||||
creatorId: data.creatorId,
|
||||
memberships: {
|
||||
create: {
|
||||
userId: data.creatorId,
|
||||
role: "OWNER",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour une maison
|
||||
*/
|
||||
async updateHouse(
|
||||
houseId: string,
|
||||
userId: string,
|
||||
data: UpdateHouseInput
|
||||
): Promise<House> {
|
||||
// Vérifier que l'utilisateur est propriétaire ou admin
|
||||
const isAuthorized = await this.isUserOwnerOrAdmin(userId, houseId);
|
||||
if (!isAuthorized) {
|
||||
throw new ForbiddenError(
|
||||
"Vous n'avez pas les permissions pour modifier cette maison"
|
||||
);
|
||||
}
|
||||
|
||||
const updateData: Prisma.HouseUpdateInput = {};
|
||||
|
||||
if (data.name !== undefined) {
|
||||
if (!data.name || data.name.trim().length === 0) {
|
||||
throw new ValidationError("Le nom de la maison est requis", "name");
|
||||
}
|
||||
|
||||
if (
|
||||
data.name.length < HOUSE_NAME_MIN_LENGTH ||
|
||||
data.name.length > HOUSE_NAME_MAX_LENGTH
|
||||
) {
|
||||
throw new ValidationError(
|
||||
`Le nom de la maison doit contenir entre ${HOUSE_NAME_MIN_LENGTH} et ${HOUSE_NAME_MAX_LENGTH} caractères`,
|
||||
"name"
|
||||
);
|
||||
}
|
||||
|
||||
// Vérifier si le nom est déjà pris par une autre maison
|
||||
const existingHouse = await prisma.house.findFirst({
|
||||
where: {
|
||||
name: {
|
||||
equals: data.name.trim(),
|
||||
mode: "insensitive",
|
||||
},
|
||||
NOT: { id: houseId },
|
||||
},
|
||||
});
|
||||
|
||||
if (existingHouse) {
|
||||
throw new ConflictError("Ce nom de maison est déjà utilisé");
|
||||
}
|
||||
|
||||
updateData.name = data.name.trim();
|
||||
}
|
||||
|
||||
if (data.description !== undefined) {
|
||||
if (data.description && data.description.length > HOUSE_DESCRIPTION_MAX_LENGTH) {
|
||||
throw new ValidationError(
|
||||
`La description ne peut pas dépasser ${HOUSE_DESCRIPTION_MAX_LENGTH} caractères`,
|
||||
"description"
|
||||
);
|
||||
}
|
||||
updateData.description = data.description?.trim() || null;
|
||||
}
|
||||
|
||||
return prisma.house.update({
|
||||
where: { id: houseId },
|
||||
data: updateData,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime une maison
|
||||
*/
|
||||
async deleteHouse(houseId: string, userId: string): Promise<void> {
|
||||
// Vérifier que l'utilisateur est propriétaire
|
||||
const isOwner = await this.isUserOwner(userId, houseId);
|
||||
if (!isOwner) {
|
||||
throw new ForbiddenError(
|
||||
"Seul le propriétaire peut supprimer la maison"
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.house.delete({
|
||||
where: { id: houseId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite un utilisateur à rejoindre une maison
|
||||
*/
|
||||
async inviteUser(data: InviteUserInput): Promise<HouseInvitation> {
|
||||
// Vérifier que l'inviteur est membre de la maison
|
||||
const isMember = await this.isUserMemberOfHouse(
|
||||
data.inviterId,
|
||||
data.houseId
|
||||
);
|
||||
if (!isMember) {
|
||||
throw new ForbiddenError(
|
||||
"Vous devez être membre de la maison pour inviter quelqu'un"
|
||||
);
|
||||
}
|
||||
|
||||
// Vérifier que l'invité n'est pas déjà membre
|
||||
const isAlreadyMember = await this.isUserMemberOfHouse(
|
||||
data.inviteeId,
|
||||
data.houseId
|
||||
);
|
||||
if (isAlreadyMember) {
|
||||
throw new ConflictError("Cet utilisateur est déjà membre de la maison");
|
||||
}
|
||||
|
||||
// Vérifier qu'il n'y a pas déjà une invitation en attente
|
||||
const existingInvitation = await prisma.houseInvitation.findUnique({
|
||||
where: {
|
||||
houseId_inviteeId: {
|
||||
houseId: data.houseId,
|
||||
inviteeId: data.inviteeId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existingInvitation && existingInvitation.status === "PENDING") {
|
||||
throw new ConflictError(
|
||||
"Une invitation est déjà en attente pour cet utilisateur"
|
||||
);
|
||||
}
|
||||
|
||||
// Vérifier que l'invité n'est pas déjà dans une autre maison
|
||||
const existingMembership = await prisma.houseMembership.findFirst({
|
||||
where: { userId: data.inviteeId },
|
||||
});
|
||||
|
||||
if (existingMembership) {
|
||||
throw new ConflictError(
|
||||
"Cet utilisateur est déjà membre d'une autre maison"
|
||||
);
|
||||
}
|
||||
|
||||
// Créer l'invitation
|
||||
return prisma.houseInvitation.create({
|
||||
data: {
|
||||
houseId: data.houseId,
|
||||
inviterId: data.inviterId,
|
||||
inviteeId: data.inviteeId,
|
||||
status: "PENDING",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepte une invitation
|
||||
*/
|
||||
async acceptInvitation(
|
||||
invitationId: string,
|
||||
userId: string
|
||||
): Promise<HouseMembership> {
|
||||
const invitation = await prisma.houseInvitation.findUnique({
|
||||
where: { id: invitationId },
|
||||
});
|
||||
|
||||
if (!invitation) {
|
||||
throw new NotFoundError("Invitation");
|
||||
}
|
||||
|
||||
if (invitation.inviteeId !== userId) {
|
||||
throw new ForbiddenError("Cette invitation ne vous est pas destinée");
|
||||
}
|
||||
|
||||
if (invitation.status !== "PENDING") {
|
||||
throw new ConflictError("Cette invitation n'est plus valide");
|
||||
}
|
||||
|
||||
// Vérifier que l'utilisateur n'est pas déjà dans une maison
|
||||
const existingMembership = await prisma.houseMembership.findFirst({
|
||||
where: { userId },
|
||||
});
|
||||
|
||||
if (existingMembership) {
|
||||
throw new ConflictError(
|
||||
"Vous êtes déjà membre d'une maison. Vous devez quitter votre maison actuelle avant d'accepter cette invitation."
|
||||
);
|
||||
}
|
||||
|
||||
// Créer le membership et mettre à jour l'invitation
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const membership = await tx.houseMembership.create({
|
||||
data: {
|
||||
houseId: invitation.houseId,
|
||||
userId: invitation.inviteeId,
|
||||
role: "MEMBER",
|
||||
},
|
||||
});
|
||||
|
||||
await tx.houseInvitation.update({
|
||||
where: { id: invitationId },
|
||||
data: { status: "ACCEPTED" },
|
||||
});
|
||||
|
||||
// Annuler toutes les autres invitations en attente pour cet utilisateur
|
||||
await tx.houseInvitation.updateMany({
|
||||
where: {
|
||||
inviteeId: userId,
|
||||
status: "PENDING",
|
||||
id: { not: invitationId },
|
||||
},
|
||||
data: { status: "CANCELLED" },
|
||||
});
|
||||
|
||||
// Annuler toutes les demandes en attente pour cet utilisateur
|
||||
await tx.houseRequest.updateMany({
|
||||
where: {
|
||||
requesterId: userId,
|
||||
status: "PENDING",
|
||||
},
|
||||
data: { status: "CANCELLED" },
|
||||
});
|
||||
|
||||
return membership;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse une invitation
|
||||
*/
|
||||
async rejectInvitation(invitationId: string, userId: string): Promise<void> {
|
||||
const invitation = await prisma.houseInvitation.findUnique({
|
||||
where: { id: invitationId },
|
||||
});
|
||||
|
||||
if (!invitation) {
|
||||
throw new NotFoundError("Invitation");
|
||||
}
|
||||
|
||||
if (invitation.inviteeId !== userId) {
|
||||
throw new ForbiddenError("Cette invitation ne vous est pas destinée");
|
||||
}
|
||||
|
||||
if (invitation.status !== "PENDING") {
|
||||
throw new ConflictError("Cette invitation n'est plus valide");
|
||||
}
|
||||
|
||||
await prisma.houseInvitation.update({
|
||||
where: { id: invitationId },
|
||||
data: { status: "REJECTED" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Annule une invitation (par l'inviteur)
|
||||
*/
|
||||
async cancelInvitation(invitationId: string, userId: string): Promise<void> {
|
||||
const invitation = await prisma.houseInvitation.findUnique({
|
||||
where: { id: invitationId },
|
||||
});
|
||||
|
||||
if (!invitation) {
|
||||
throw new NotFoundError("Invitation");
|
||||
}
|
||||
|
||||
if (invitation.inviterId !== userId) {
|
||||
throw new ForbiddenError(
|
||||
"Vous ne pouvez annuler que vos propres invitations"
|
||||
);
|
||||
}
|
||||
|
||||
if (invitation.status !== "PENDING") {
|
||||
throw new ConflictError("Cette invitation n'est plus valide");
|
||||
}
|
||||
|
||||
await prisma.houseInvitation.update({
|
||||
where: { id: invitationId },
|
||||
data: { status: "CANCELLED" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Demande à rejoindre une maison
|
||||
*/
|
||||
async requestToJoin(data: RequestToJoinInput): Promise<HouseRequest> {
|
||||
// Vérifier que l'utilisateur n'est pas déjà membre
|
||||
const isMember = await this.isUserMemberOfHouse(
|
||||
data.requesterId,
|
||||
data.houseId
|
||||
);
|
||||
if (isMember) {
|
||||
throw new ConflictError("Vous êtes déjà membre de cette maison");
|
||||
}
|
||||
|
||||
// Vérifier que l'utilisateur n'est pas déjà dans une autre maison
|
||||
const existingMembership = await prisma.houseMembership.findFirst({
|
||||
where: { userId: data.requesterId },
|
||||
});
|
||||
|
||||
if (existingMembership) {
|
||||
throw new ConflictError(
|
||||
"Vous êtes déjà membre d'une maison. Vous devez quitter votre maison actuelle avant de faire une demande."
|
||||
);
|
||||
}
|
||||
|
||||
// Vérifier qu'il n'y a pas déjà une demande en attente
|
||||
const existingRequest = await prisma.houseRequest.findUnique({
|
||||
where: {
|
||||
houseId_requesterId: {
|
||||
houseId: data.houseId,
|
||||
requesterId: data.requesterId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existingRequest && existingRequest.status === "PENDING") {
|
||||
throw new ConflictError(
|
||||
"Une demande est déjà en attente pour cette maison"
|
||||
);
|
||||
}
|
||||
|
||||
// Créer la demande
|
||||
return prisma.houseRequest.create({
|
||||
data: {
|
||||
houseId: data.houseId,
|
||||
requesterId: data.requesterId,
|
||||
status: "PENDING",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepte une demande d'adhésion
|
||||
*/
|
||||
async acceptRequest(
|
||||
requestId: string,
|
||||
userId: string
|
||||
): Promise<HouseMembership> {
|
||||
const request = await prisma.houseRequest.findUnique({
|
||||
where: { id: requestId },
|
||||
include: { house: true },
|
||||
});
|
||||
|
||||
if (!request) {
|
||||
throw new NotFoundError("Demande");
|
||||
}
|
||||
|
||||
// Vérifier que l'utilisateur est propriétaire ou admin de la maison
|
||||
const isAuthorized = await this.isUserOwnerOrAdmin(
|
||||
userId,
|
||||
request.houseId
|
||||
);
|
||||
if (!isAuthorized) {
|
||||
throw new ForbiddenError(
|
||||
"Vous n'avez pas les permissions pour accepter cette demande"
|
||||
);
|
||||
}
|
||||
|
||||
if (request.status !== "PENDING") {
|
||||
throw new ConflictError("Cette demande n'est plus valide");
|
||||
}
|
||||
|
||||
// Vérifier que le demandeur n'est pas déjà dans une maison
|
||||
const existingMembership = await prisma.houseMembership.findFirst({
|
||||
where: { userId: request.requesterId },
|
||||
});
|
||||
|
||||
if (existingMembership) {
|
||||
throw new ConflictError(
|
||||
"Cet utilisateur est déjà membre d'une autre maison"
|
||||
);
|
||||
}
|
||||
|
||||
// Créer le membership et mettre à jour la demande
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const membership = await tx.houseMembership.create({
|
||||
data: {
|
||||
houseId: request.houseId,
|
||||
userId: request.requesterId,
|
||||
role: "MEMBER",
|
||||
},
|
||||
});
|
||||
|
||||
await tx.houseRequest.update({
|
||||
where: { id: requestId },
|
||||
data: { status: "ACCEPTED" },
|
||||
});
|
||||
|
||||
// Annuler toutes les autres demandes en attente pour cet utilisateur
|
||||
await tx.houseRequest.updateMany({
|
||||
where: {
|
||||
requesterId: request.requesterId,
|
||||
status: "PENDING",
|
||||
id: { not: requestId },
|
||||
},
|
||||
data: { status: "CANCELLED" },
|
||||
});
|
||||
|
||||
// Annuler toutes les invitations en attente pour cet utilisateur
|
||||
await tx.houseInvitation.updateMany({
|
||||
where: {
|
||||
inviteeId: request.requesterId,
|
||||
status: "PENDING",
|
||||
},
|
||||
data: { status: "CANCELLED" },
|
||||
});
|
||||
|
||||
return membership;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse une demande d'adhésion
|
||||
*/
|
||||
async rejectRequest(requestId: string, userId: string): Promise<void> {
|
||||
const request = await prisma.houseRequest.findUnique({
|
||||
where: { id: requestId },
|
||||
});
|
||||
|
||||
if (!request) {
|
||||
throw new NotFoundError("Demande");
|
||||
}
|
||||
|
||||
// Vérifier que l'utilisateur est propriétaire ou admin de la maison
|
||||
const isAuthorized = await this.isUserOwnerOrAdmin(
|
||||
userId,
|
||||
request.houseId
|
||||
);
|
||||
if (!isAuthorized) {
|
||||
throw new ForbiddenError(
|
||||
"Vous n'avez pas les permissions pour refuser cette demande"
|
||||
);
|
||||
}
|
||||
|
||||
if (request.status !== "PENDING") {
|
||||
throw new ConflictError("Cette demande n'est plus valide");
|
||||
}
|
||||
|
||||
await prisma.houseRequest.update({
|
||||
where: { id: requestId },
|
||||
data: { status: "REJECTED" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Annule une demande (par le demandeur)
|
||||
*/
|
||||
async cancelRequest(requestId: string, userId: string): Promise<void> {
|
||||
const request = await prisma.houseRequest.findUnique({
|
||||
where: { id: requestId },
|
||||
});
|
||||
|
||||
if (!request) {
|
||||
throw new NotFoundError("Demande");
|
||||
}
|
||||
|
||||
if (request.requesterId !== userId) {
|
||||
throw new ForbiddenError(
|
||||
"Vous ne pouvez annuler que vos propres demandes"
|
||||
);
|
||||
}
|
||||
|
||||
if (request.status !== "PENDING") {
|
||||
throw new ConflictError("Cette demande n'est plus valide");
|
||||
}
|
||||
|
||||
await prisma.houseRequest.update({
|
||||
where: { id: requestId },
|
||||
data: { status: "CANCELLED" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Quitte une maison
|
||||
*/
|
||||
async leaveHouse(houseId: string, userId: string): Promise<void> {
|
||||
const membership = await prisma.houseMembership.findUnique({
|
||||
where: {
|
||||
houseId_userId: {
|
||||
houseId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFoundError("Membre");
|
||||
}
|
||||
|
||||
// Le propriétaire ne peut pas quitter sa maison
|
||||
if (membership.role === "OWNER") {
|
||||
throw new ForbiddenError(
|
||||
"Le propriétaire ne peut pas quitter sa maison. Vous devez d'abord transférer la propriété ou supprimer la maison."
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.houseMembership.delete({
|
||||
where: {
|
||||
houseId_userId: {
|
||||
houseId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les invitations reçues par un utilisateur
|
||||
*/
|
||||
async getUserInvitations(
|
||||
userId: string,
|
||||
status?: InvitationStatus
|
||||
): Promise<HouseInvitation[]> {
|
||||
return prisma.houseInvitation.findMany({
|
||||
where: {
|
||||
inviteeId: userId,
|
||||
...(status && { status }),
|
||||
},
|
||||
include: {
|
||||
house: true,
|
||||
inviter: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
avatar: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les demandes d'une maison
|
||||
*/
|
||||
async getHouseRequests(
|
||||
houseId: string,
|
||||
status?: RequestStatus
|
||||
): Promise<HouseRequest[]> {
|
||||
return prisma.houseRequest.findMany({
|
||||
where: {
|
||||
houseId,
|
||||
...(status && { status }),
|
||||
},
|
||||
include: {
|
||||
requester: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
avatar: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les invitations envoyées par une maison
|
||||
*/
|
||||
async getHouseInvitations(
|
||||
houseId: string,
|
||||
status?: InvitationStatus
|
||||
): Promise<HouseInvitation[]> {
|
||||
return prisma.houseInvitation.findMany({
|
||||
where: {
|
||||
houseId,
|
||||
...(status && { status }),
|
||||
},
|
||||
include: {
|
||||
invitee: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
avatar: true,
|
||||
},
|
||||
},
|
||||
inviter: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
avatar: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère une invitation par son ID (avec seulement houseId)
|
||||
*/
|
||||
async getInvitationById(
|
||||
id: string
|
||||
): Promise<{ houseId: string } | null> {
|
||||
return prisma.houseInvitation.findUnique({
|
||||
where: { id },
|
||||
select: { houseId: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const houseService = new HouseService();
|
||||
|
||||
@@ -27,6 +27,26 @@ export interface LeaderboardEntry {
|
||||
characterClass: CharacterClass | null;
|
||||
}
|
||||
|
||||
export interface HouseMember {
|
||||
id: string;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
score: number;
|
||||
level: number;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface HouseLeaderboardEntry {
|
||||
rank: number;
|
||||
houseId: string;
|
||||
houseName: string;
|
||||
totalScore: number;
|
||||
memberCount: number;
|
||||
averageScore: number;
|
||||
description: string | null;
|
||||
members: HouseMember[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Service de gestion des statistiques utilisateur
|
||||
*/
|
||||
@@ -64,6 +84,72 @@ export class UserStatsService {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère le leaderboard par maison
|
||||
*/
|
||||
async getHouseLeaderboard(limit: number = 10): Promise<HouseLeaderboardEntry[]> {
|
||||
// Récupérer toutes les maisons avec leurs membres et leurs scores
|
||||
const houses = await prisma.house.findMany({
|
||||
include: {
|
||||
memberships: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
avatar: true,
|
||||
score: true,
|
||||
level: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{ role: "asc" }, // OWNER, ADMIN, MEMBER
|
||||
{ user: { score: "desc" } }, // Puis par score décroissant
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Calculer le score total et la moyenne pour chaque maison
|
||||
const houseStats = houses
|
||||
.map((house) => {
|
||||
const memberScores = house.memberships.map((m) => m.user.score);
|
||||
const totalScore = memberScores.reduce((sum, score) => sum + score, 0);
|
||||
const memberCount = house.memberships.length;
|
||||
const averageScore = memberCount > 0 ? Math.floor(totalScore / memberCount) : 0;
|
||||
|
||||
// Mapper les membres avec leurs détails
|
||||
const members: HouseMember[] = house.memberships.map((membership) => ({
|
||||
id: membership.user.id,
|
||||
username: membership.user.username,
|
||||
avatar: membership.user.avatar,
|
||||
score: membership.user.score,
|
||||
level: membership.user.level,
|
||||
role: membership.role,
|
||||
}));
|
||||
|
||||
return {
|
||||
houseId: house.id,
|
||||
houseName: house.name,
|
||||
totalScore,
|
||||
memberCount,
|
||||
averageScore,
|
||||
description: house.description,
|
||||
members,
|
||||
};
|
||||
})
|
||||
.filter((house) => house.memberCount > 0) // Exclure les maisons sans membres
|
||||
.sort((a, b) => b.totalScore - a.totalScore) // Trier par score total décroissant
|
||||
.slice(0, limit) // Limiter le nombre de résultats
|
||||
.map((house, index) => ({
|
||||
rank: index + 1,
|
||||
...house,
|
||||
}));
|
||||
|
||||
return houseStats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour les statistiques d'un utilisateur
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user