Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,015 changes: 533 additions & 482 deletions apps/api/openapi.yaml

Large diffs are not rendered by default.

525 changes: 525 additions & 0 deletions apps/api/src/chat/chat.service.spec.ts

Large diffs are not rendered by default.

33 changes: 31 additions & 2 deletions apps/api/src/chat/chat.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,14 @@ import {
} from "@nestjs/common";
import { WINSTON_MODULE_PROVIDER } from "nest-winston";
import type { Logger } from "winston";
import type { SendMessageDto } from "src/chat/dto/send-message.dto";
import {
MessageType,
type SendMessageDto,
} from "src/chat/dto/send-message.dto";
import { DEFAULT_EXCHANGE_NAME } from "src/config/rabbitmq.config";
import { RoutingKey } from "src/enum/routing-key.enum";
import { RedisService } from "@lootlog/nest-shared";
import { getNpcRoutingTier, type NpcRoutingTier } from "@lootlog/types";
import { v6 } from "uuid";
import { isAdministrativeUser } from "src/shared/permissions/is-administrative-user";
import { GuildsService } from "src/guilds/guilds.service";
Expand All @@ -26,6 +30,11 @@ type ChatMessage = SendMessageDto & {
guildId: string;
};

type MessageRouting = {
tier: NpcRoutingTier;
npcLevel?: number;
};

@Injectable()
export class ChatService {
constructor(
Expand Down Expand Up @@ -182,6 +191,7 @@ export class ChatService {
message: newMessage,
partyGathering: undefined,
};
const routing = this.getMessageRouting(message);

await this.redisService.lset(key, messageIndex, JSON.stringify(updated));

Expand All @@ -192,6 +202,7 @@ export class ChatService {
guildId,
messageId,
message: newMessage,
routing,
},
);

Expand All @@ -215,15 +226,33 @@ export class ChatService {
if (message.senderId !== discordId) {
throw new ForbiddenException("Not the owner of this message");
}
const routing = this.getMessageRouting(message);

await this.redisService.lrem(key, 1, targetElement);

this.amqpConnection.publish(
DEFAULT_EXCHANGE_NAME,
RoutingKey.GUILDS_DELETE_MESSAGE,
{ guildId, messageId },
{ guildId, messageId, routing },
);

return { success: true };
}

private getMessageRouting(
message: Pick<SendMessageDto, "type" | "npc">,
): MessageRouting {
const hasNpcScopedRouting =
message.type === MessageType.NPC ||
(message.type === MessageType.PARTY_GATHERING && message.npc);

if (!hasNpcScopedRouting || !message.npc) {
return { tier: "base" };
}

return {
tier: getNpcRoutingTier(message.npc),
npcLevel: message.npc.lvl,
};
}
}
24 changes: 24 additions & 0 deletions apps/api/src/members/members.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ describe("MembersController", () => {
getGuildMemberById: Mock;
refreshMember: Mock;
getGuildMembers: Mock;
getGuildMembersSummary: Mock;
createBulkRefreshJob: Mock;
getLatestRefreshJob: Mock;
getRefreshJobStatus: Mock;
Expand Down Expand Up @@ -70,6 +71,7 @@ describe("MembersController", () => {
getGuildMemberById: mockFn(),
refreshMember: mockFn(),
getGuildMembers: mockFn(),
getGuildMembersSummary: mockFn(),
createBulkRefreshJob: mockFn(),
getLatestRefreshJob: mockFn(),
getRefreshJobStatus: mockFn(),
Expand Down Expand Up @@ -200,6 +202,28 @@ describe("MembersController", () => {
});
});

describe("getGuildMembersSummary", () => {
it("should return lightweight guild members", async () => {
const members = [
{
id: 123,
userId: "discord-123",
name: "Test User",
avatar: "avatar.png",
color: 123456,
},
];
membersService.getGuildMembersSummary.mockResolvedValue(members);

const result = await controller.getGuildMembersSummary(mockGuild);

expect(result).toEqual(members);
expect(membersService.getGuildMembersSummary).toHaveBeenCalledWith(
mockGuild.id,
);
});
});

describe("refreshAllMembers", () => {
it("should create bulk refresh job", async () => {
membersService.createBulkRefreshJob.mockResolvedValue(mockRefreshJob);
Expand Down
23 changes: 23 additions & 0 deletions apps/api/src/members/members.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
MemberResponseDto,
NullableMemberResponseDto,
} from "src/shared/dto/member-response.dto";
import { MemberSummaryResponseDto } from "src/shared/dto/member-summary-response.dto";

@ApiTags("members")
@ApiBearerAuth()
Expand Down Expand Up @@ -189,6 +190,28 @@ export class MembersController {
return this.membersService.getGuildMembers(guild.id, includeInactiveBool);
}

@Permissions(Permission.LOOTLOG_ACCESS)
@UseGuards(PermissionsGuard)
@Get("summary")
@ApiOperation({
summary: "Get guild members summary",
description:
"Retrieve lightweight active member data for game-client member lookups",
})
@ApiParam({ name: "guildId", description: "Guild ID", example: "guild_123" })
@ZodResponse({
status: 200,
description: "Lightweight list of guild members",
type: [MemberSummaryResponseDto],
})
@ApiResponse({
status: 403,
description: "Forbidden - insufficient permissions",
})
async getGuildMembersSummary(@GuildData() guild: Guild) {
return this.membersService.getGuildMembersSummary(guild.id);
}

@Permissions(Permission.ADMIN, Permission.OWNER)
@UseGuards(PermissionsGuard)
@Post("refresh-all")
Expand Down
107 changes: 107 additions & 0 deletions apps/api/src/members/members.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { ErrorKey } from "./enum/error-key.enum";
import { RuntimeEnvironment } from "src/types/runtime.types";
import type { APIGuildMember } from "discord-api-types/v10";
import {
Permission,
type Member,
type Guild,
MemberType,
Expand Down Expand Up @@ -583,6 +584,112 @@ describe("MembersService", () => {
});
});

describe("getGuildMembersSummary", () => {
it("should return lightweight active members for a guild", async () => {
prismaService.guild.findFirst.mockResolvedValue({
ownerId: "owner-123",
});
prismaService.member.findMany.mockResolvedValue([
{
id: 123,
userId: "discord-123",
name: "Alpha",
avatar: "avatar.png",
roles: [{ color: 123456 }],
},
{
id: 456,
userId: "discord-456",
name: "Beta",
avatar: null,
roles: [],
},
]);

const result = await service.getGuildMembersSummary("guild-123");

expect(result).toEqual([
{
id: 123,
userId: "discord-123",
name: "Alpha",
avatar: "avatar.png",
color: 123456,
},
{
id: 456,
userId: "discord-456",
name: "Beta",
avatar: null,
color: null,
},
]);
expect(prismaService.member.findMany).toHaveBeenCalledWith({
where: {
guildId: "guild-123",
active: true,
globalUserId: { not: null },
OR: [
{
userId: "owner-123",
},
{
roles: {
some: {
permissions: {
hasSome: [
Permission.OWNER,
Permission.ADMIN,
Permission.LOOTLOG_ACCESS,
],
},
},
},
},
],
},
select: {
id: true,
userId: true,
name: true,
avatar: true,
roles: {
select: {
color: true,
},
orderBy: {
position: "desc",
},
take: 1,
},
},
orderBy: {
name: "asc",
},
});
});

it("should return empty array when guild does not exist", async () => {
prismaService.guild.findFirst.mockResolvedValue(null);

const result = await service.getGuildMembersSummary("guild-123");

expect(result).toEqual([]);
expect(prismaService.member.findMany).not.toHaveBeenCalled();
});

it("should return empty array when no lightweight members found", async () => {
prismaService.guild.findFirst.mockResolvedValue({
ownerId: "owner-123",
});
prismaService.member.findMany.mockResolvedValue([]);

const result = await service.getGuildMembersSummary("guild-123");

expect(result).toEqual([]);
});
});

describe("createOrUpdateMember", () => {
const memberData = {
...mockDiscordMember,
Expand Down
80 changes: 79 additions & 1 deletion apps/api/src/members/members.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@ import {
import type { APIGuildMember } from "discord-api-types/v10";
import { ErrorKey } from "src/members/enum/error-key.enum";
import { ErrorKey as GuildErrorKey } from "src/guilds/enum/error-key.enum";
import type { Member, Prisma, Role } from "src/generated/prisma/client";
import {
Permission,
type Member,
type Prisma,
type Role,
} from "src/generated/prisma/client";
import { DEFAULT_EXCHANGE_NAME } from "src/config/rabbitmq.config";
import { RoutingKey } from "src/enum/routing-key.enum";
import { serviceConfig } from "src/config/service.config";
Expand Down Expand Up @@ -56,6 +61,14 @@ type StoredMemberWithRoles = Member & {
roles: Role[];
};

type MemberSummary = {
id: number;
userId: string;
name: string;
avatar: string | null;
color: number | null;
};

type MemberRemovalNotificationTarget = {
discordId: string;
guildId: string;
Expand Down Expand Up @@ -450,6 +463,71 @@ export class MembersService {
});
}

async getGuildMembersSummary(guildId: string): Promise<MemberSummary[]> {
const guild = await this.prisma.guild.findFirst({
where: {
id: guildId,
active: true,
},
select: {
ownerId: true,
},
});

if (!guild) {
return [];
}

const members = await this.prisma.member.findMany({
where: {
guildId,
active: true,
globalUserId: { not: null },
OR: [
{
userId: guild.ownerId,
},
{
roles: {
some: {
permissions: {
hasSome: [
Permission.OWNER,
Permission.ADMIN,
Permission.LOOTLOG_ACCESS,
],
},
},
},
},
],
},
select: {
id: true,
userId: true,
name: true,
avatar: true,
roles: {
select: {
color: true,
},
orderBy: {
position: "desc",
},
take: 1,
},
},
orderBy: {
name: "asc",
},
});

return members.map(({ roles, ...member }) => ({
...member,
color: roles[0]?.color ?? null,
}));
}

isMemberSoftStale(
member: Pick<Member, "lastDiscordSyncAt" | "updatedAt"> | null | undefined,
): boolean {
Expand Down
Loading
Loading