mirror of
https://github.com/immich-app/yucca.git
synced 2026-09-30 13:33:00 +08:00
feat(yucca-admin-api): manage and cancel discord beta-invite drops via yuctl (#575)
This commit is contained in:
@@ -2,6 +2,7 @@ import { LoggerRepository, LoggingInterceptor, OtelModule, WideContextRepository
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { InternalController } from './controllers/internal.controller';
|
||||
import { DiscordRepository } from './repositories/discord.repository';
|
||||
import { TranscriptStorageRepository } from './repositories/transcriptStorage.repository';
|
||||
import { YuccaApiRepository } from './repositories/yuccaApi.repository';
|
||||
@@ -25,6 +26,7 @@ export const providers = [
|
||||
|
||||
@Module({
|
||||
imports: [OtelModule, ...imports],
|
||||
controllers: [InternalController],
|
||||
providers,
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { BadRequestException, Body, Controller, HttpCode, HttpStatus, Post, UseGuards } from '@nestjs/common';
|
||||
import { InternalGuard } from 'src/middleware/internal.guard';
|
||||
import { InviteService } from 'src/services/invite.service';
|
||||
import { z } from 'zod';
|
||||
|
||||
const closeDropSchema = z.object({
|
||||
batchId: z.string().min(1),
|
||||
channelId: z.string().min(1),
|
||||
messageId: z.string().min(1),
|
||||
});
|
||||
|
||||
@Controller('/internal/drops')
|
||||
@UseGuards(InternalGuard)
|
||||
export class InternalController {
|
||||
constructor(private readonly invite: InviteService) {}
|
||||
|
||||
@Post('/close')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async closeDrop(@Body() body: unknown): Promise<void> {
|
||||
const parsed = closeDropSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestException(parsed.error.message);
|
||||
}
|
||||
await this.invite.closeDrop(parsed.data.batchId, parsed.data.channelId, parsed.data.messageId);
|
||||
}
|
||||
}
|
||||
@@ -63,4 +63,6 @@ export const Messages = {
|
||||
claimAlreadyLinked: 'You already have a FUTO Backups account, no invite needed.',
|
||||
claimInviteUsed: 'You already used your beta invite.',
|
||||
claimExhausted: 'All invites have been claimed, keep an eye out for the next drop.',
|
||||
claimDropEnded: 'This drop has ended, keep an eye out for the next one.',
|
||||
inviteDropEndedButton: 'Drop ended',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { createHash, timingSafeEqual } from 'node:crypto';
|
||||
import { env } from 'src/env';
|
||||
|
||||
export const INTERNAL_SECRET_HEADER = 'x-internal-secret';
|
||||
|
||||
const digest = (value: string) => createHash('sha256').update(value).digest();
|
||||
|
||||
@Injectable()
|
||||
export class InternalGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest<{ headers: Record<string, string | string[] | undefined> }>();
|
||||
const secret = request.headers[INTERNAL_SECRET_HEADER];
|
||||
if (
|
||||
!env.INTERNAL_SECRET ||
|
||||
typeof secret !== 'string' ||
|
||||
!timingSafeEqual(digest(secret), digest(env.INTERNAL_SECRET))
|
||||
) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Interaction,
|
||||
Message,
|
||||
MessageCreateOptions,
|
||||
MessageEditOptions,
|
||||
SlashCommandBuilder,
|
||||
TextChannel,
|
||||
ThreadChannel,
|
||||
@@ -96,6 +97,12 @@ export class DiscordRepository {
|
||||
return channel.send(message);
|
||||
}
|
||||
|
||||
async editMessage(channelId: string, messageId: string, edit: MessageEditOptions): Promise<void> {
|
||||
const channel = await this.textChannel(channelId);
|
||||
const message = await channel.messages.fetch(messageId);
|
||||
await message.edit(edit);
|
||||
}
|
||||
|
||||
async sendDirectMessage(discordUserId: string, message: MessageCreateOptions): Promise<boolean> {
|
||||
const user = await this.requireClient().users.fetch(discordUserId);
|
||||
try {
|
||||
|
||||
@@ -42,7 +42,7 @@ export type UserSummary = z.infer<typeof userSummarySchema>;
|
||||
export type LinkRequestCreated = z.infer<typeof linkRequestCreatedSchema>;
|
||||
export type InviteResult =
|
||||
| ({ status: 'ok' } & z.infer<typeof inviteCreatedSchema>)
|
||||
| { status: 'already-linked' | 'invite-used' | 'exhausted' };
|
||||
| { status: 'already-linked' | 'invite-used' | 'exhausted' | 'cancelled' };
|
||||
|
||||
@Injectable()
|
||||
export class YuccaApiRepository {
|
||||
@@ -116,6 +116,9 @@ export class YuccaApiRepository {
|
||||
case 'BATCH_EXHAUSTED': {
|
||||
return { status: 'exhausted' };
|
||||
}
|
||||
case 'BATCH_CANCELLED': {
|
||||
return { status: 'cancelled' };
|
||||
}
|
||||
}
|
||||
throw new Error(`Unexpected invite conflict: ${message}`);
|
||||
}
|
||||
|
||||
@@ -178,6 +178,18 @@ describe(InviteService.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('closeDrop', () => {
|
||||
it('disables the drop button on the posted message', async () => {
|
||||
await sut.closeDrop('batch-1', 'channel-1', 'message-1');
|
||||
|
||||
expect(mocks.discord.editMessage).toHaveBeenCalledWith(
|
||||
'channel-1',
|
||||
'message-1',
|
||||
expect.objectContaining({ components: [expect.anything()] }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onClaimInvite', () => {
|
||||
it('replies with a personal link on a successful claim', async () => {
|
||||
mocks.api.createInvite.mockResolvedValue({ ...okInvite, remaining: 3 });
|
||||
@@ -224,6 +236,18 @@ describe(InviteService.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('reports a cancelled drop and disables the button', async () => {
|
||||
mocks.api.createInvite.mockResolvedValue({ status: 'cancelled' });
|
||||
const interaction = newClaimInteraction();
|
||||
|
||||
await sut.onClaimInvite(interaction);
|
||||
|
||||
expect((interaction as { message: { edit: jest.Mock } }).message.edit).toHaveBeenCalled();
|
||||
expect((interaction as { editReply: jest.Mock }).editReply).toHaveBeenCalledWith({
|
||||
content: Messages.claimDropEnded,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports a spent invite', async () => {
|
||||
mocks.api.createInvite.mockResolvedValue({ status: 'invite-used' });
|
||||
const interaction = newClaimInteraction();
|
||||
|
||||
@@ -131,6 +131,13 @@ export class InviteService {
|
||||
});
|
||||
return;
|
||||
}
|
||||
case 'cancelled': {
|
||||
await interaction.message
|
||||
.edit({ components: [this.claimRow(batchId, Messages.inviteDropEndedButton, true)] })
|
||||
.catch((error: unknown) => this.logger.warn(error, 'could not disable the claim button'));
|
||||
await interaction.editReply({ content: Messages.claimDropEnded });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await interaction.editReply({
|
||||
@@ -142,6 +149,12 @@ export class InviteService {
|
||||
}
|
||||
}
|
||||
|
||||
async closeDrop(batchId: string, channelId: string, messageId: string): Promise<void> {
|
||||
await this.discord.editMessage(channelId, messageId, {
|
||||
components: [this.claimRow(batchId, Messages.inviteDropEndedButton, true)],
|
||||
});
|
||||
}
|
||||
|
||||
private async disableClaimButton(interaction: ButtonInteraction, batchId: string): Promise<void> {
|
||||
await interaction.message
|
||||
.edit({ components: [this.claimRow(batchId, Messages.inviteDropClaimedButton, true)] })
|
||||
|
||||
@@ -24,6 +24,7 @@ export const newDiscordRepositoryMock = (): jest.Mocked<RepositoryInterface<Disc
|
||||
listRecentMessages: jest.fn().mockResolvedValue([]),
|
||||
sendMessage: jest.fn(),
|
||||
sendDirectMessage: jest.fn().mockResolvedValue(true),
|
||||
editMessage: jest.fn().mockResolvedValue(void 0),
|
||||
createTicketThread: jest.fn(),
|
||||
createStaffThread: jest.fn(),
|
||||
listOpenTicketThreads: jest.fn().mockResolvedValue([]),
|
||||
|
||||
@@ -899,6 +899,114 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/discord-invites/batches": {
|
||||
"get": {
|
||||
"operationId": "listBatches",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DiscordInviteBatchListResponseDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"DiscordInvite"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/discord-invites/batches/{id}": {
|
||||
"delete": {
|
||||
"operationId": "cancelBatch",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "revokeUnused",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Also delete the batch’s unredeemed claims",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"true",
|
||||
"false"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DiscordInviteBatchCancelResponseDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"DiscordInvite"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/discord-invites": {
|
||||
"get": {
|
||||
"operationId": "listClaims",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DiscordInviteClaimListResponseDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"DiscordInvite"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/discord-invites/{discordUserId}": {
|
||||
"delete": {
|
||||
"operationId": "revokeClaim",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "discordUserId",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"DiscordInvite"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/settings": {
|
||||
"get": {
|
||||
"operationId": "listSettings",
|
||||
@@ -1596,7 +1704,8 @@
|
||||
"type": "string"
|
||||
},
|
||||
"email": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"inviteCode": {
|
||||
"type": "string"
|
||||
@@ -1621,7 +1730,6 @@
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"email",
|
||||
"inviteCode",
|
||||
"invited",
|
||||
"inviteUsed",
|
||||
@@ -1711,6 +1819,132 @@
|
||||
"count"
|
||||
]
|
||||
},
|
||||
"DiscordInviteBatchDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"guildId": {
|
||||
"type": "string"
|
||||
},
|
||||
"channelId": {
|
||||
"type": "string"
|
||||
},
|
||||
"messageId": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"maxClaims": {
|
||||
"type": "number"
|
||||
},
|
||||
"claimed": {
|
||||
"type": "number"
|
||||
},
|
||||
"used": {
|
||||
"type": "number"
|
||||
},
|
||||
"createdByDiscordUserId": {
|
||||
"type": "string"
|
||||
},
|
||||
"cancelledAt": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"guildId",
|
||||
"channelId",
|
||||
"maxClaims",
|
||||
"claimed",
|
||||
"used",
|
||||
"createdByDiscordUserId",
|
||||
"createdAt"
|
||||
]
|
||||
},
|
||||
"DiscordInviteBatchListResponseDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/DiscordInviteBatchDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"items"
|
||||
]
|
||||
},
|
||||
"DiscordInviteBatchCancelResponseDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"batch": {
|
||||
"$ref": "#/components/schemas/DiscordInviteBatchDto"
|
||||
},
|
||||
"revokedClaims": {
|
||||
"type": "number",
|
||||
"description": "Unredeemed claims deleted by --revoke-unused"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"batch",
|
||||
"revokedClaims"
|
||||
]
|
||||
},
|
||||
"DiscordInviteClaimDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"discordUserId": {
|
||||
"type": "string"
|
||||
},
|
||||
"discordUsername": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"batchId": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"inviteUsed": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"inviteUsedAt": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"discordUserId",
|
||||
"inviteUsed",
|
||||
"createdAt"
|
||||
]
|
||||
},
|
||||
"DiscordInviteClaimListResponseDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/DiscordInviteClaimDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"items"
|
||||
]
|
||||
},
|
||||
"SettingsValueDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { KyselyModule } from 'nestjs-kysely';
|
||||
import { createPublicKey } from 'node:crypto';
|
||||
import { AllowlistController } from './controllers/allowlist.controller';
|
||||
import { AuthController } from './controllers/auth.controller';
|
||||
import { DiscordInviteController } from './controllers/discordInvite.controller';
|
||||
import { FeaturesController } from './controllers/features.controller';
|
||||
import { RepositoryController } from './controllers/repository.controller';
|
||||
import { SessionController } from './controllers/session.controller';
|
||||
@@ -16,8 +17,10 @@ import { env } from './env';
|
||||
import { AuthGuard } from './middleware/auth.guard';
|
||||
import { ConnectionRepository } from './repositories/connection.repository';
|
||||
import { DatabaseRepository } from './repositories/database.repository';
|
||||
import { DiscordInviteRepository } from './repositories/discordInvite.repository';
|
||||
import { DiscordLinkRepository } from './repositories/discordLink.repository';
|
||||
import { FeatureFlagRepository } from './repositories/featureFlag.repository';
|
||||
import { FutoBackupsBotRepository } from './repositories/futoBackupsBot.repository';
|
||||
import { OidcRepository } from './repositories/oidc.repository';
|
||||
import { RepositoryRepository } from './repositories/repository.repository';
|
||||
import { SessionRepository } from './repositories/session.repository';
|
||||
@@ -29,6 +32,7 @@ import { UserAllowlistRepository } from './repositories/userAllowlist.repository
|
||||
import { AllowlistService } from './services/allowlist.service';
|
||||
import { AuthService } from './services/auth.service';
|
||||
import { DatabaseService } from './services/database.service';
|
||||
import { DiscordInviteService } from './services/discordInvite.service';
|
||||
import { FeaturesService } from './services/features.service';
|
||||
import { RepositoryService } from './services/repository.service';
|
||||
import { SessionService } from './services/session.service';
|
||||
@@ -56,6 +60,7 @@ export const controllers = [
|
||||
SessionController,
|
||||
RepositoryController,
|
||||
AllowlistController,
|
||||
DiscordInviteController,
|
||||
SettingsController,
|
||||
FeaturesController,
|
||||
];
|
||||
@@ -65,7 +70,9 @@ export const providers = [
|
||||
LoggerRepository,
|
||||
EmailRepository,
|
||||
DatabaseRepository,
|
||||
DiscordInviteRepository,
|
||||
DiscordLinkRepository,
|
||||
FutoBackupsBotRepository,
|
||||
DatabaseService,
|
||||
OidcRepository,
|
||||
UserRepository,
|
||||
@@ -78,6 +85,7 @@ export const providers = [
|
||||
ConnectionRepository,
|
||||
FeatureFlagRepository,
|
||||
AllowlistService,
|
||||
DiscordInviteService,
|
||||
AuthService,
|
||||
UserService,
|
||||
SessionService,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Query } from '@nestjs/common';
|
||||
import { ApiOkResponse } from '@nestjs/swagger';
|
||||
import {
|
||||
DiscordInviteBatchCancelQueryDto,
|
||||
DiscordInviteBatchCancelResponseDto,
|
||||
DiscordInviteBatchListResponseDto,
|
||||
DiscordInviteClaimListResponseDto,
|
||||
} from 'src/dto/discordInvite.dto';
|
||||
import { AuthRoute } from 'src/middleware/auth.guard';
|
||||
import { DiscordInviteService } from 'src/services/discordInvite.service';
|
||||
|
||||
@Controller('/discord-invites')
|
||||
export class DiscordInviteController {
|
||||
constructor(private readonly invites: DiscordInviteService) {}
|
||||
|
||||
@Get('/batches')
|
||||
@AuthRoute()
|
||||
@ApiOkResponse({ type: DiscordInviteBatchListResponseDto })
|
||||
listBatches(): Promise<DiscordInviteBatchListResponseDto> {
|
||||
return this.invites.listBatches();
|
||||
}
|
||||
|
||||
@Delete('/batches/:id')
|
||||
@AuthRoute()
|
||||
@ApiOkResponse({ type: DiscordInviteBatchCancelResponseDto })
|
||||
cancelBatch(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Query() query: DiscordInviteBatchCancelQueryDto,
|
||||
): Promise<DiscordInviteBatchCancelResponseDto> {
|
||||
return this.invites.cancelBatch(id, query.revokeUnused === 'true');
|
||||
}
|
||||
|
||||
@Get()
|
||||
@AuthRoute()
|
||||
@ApiOkResponse({ type: DiscordInviteClaimListResponseDto })
|
||||
listClaims(): Promise<DiscordInviteClaimListResponseDto> {
|
||||
return this.invites.listClaims();
|
||||
}
|
||||
|
||||
@Delete('/:discordUserId')
|
||||
@AuthRoute()
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
revokeClaim(@Param('discordUserId') discordUserId: string): Promise<void> {
|
||||
return this.invites.revokeClaim(discordUserId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsIn, IsOptional } from 'class-validator';
|
||||
|
||||
export class DiscordInviteClaimDto {
|
||||
@ApiProperty()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
discordUserId!: string;
|
||||
|
||||
@ApiProperty({ type: 'string', required: false, nullable: true })
|
||||
discordUsername!: string | null;
|
||||
|
||||
@ApiProperty({ type: 'string', required: false, nullable: true })
|
||||
batchId!: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
inviteUsed!: boolean;
|
||||
|
||||
@ApiProperty({ type: 'string', required: false, nullable: true })
|
||||
inviteUsedAt!: Date | null;
|
||||
|
||||
@ApiProperty({ type: 'string' })
|
||||
createdAt!: Date;
|
||||
}
|
||||
|
||||
export class DiscordInviteClaimListResponseDto {
|
||||
@ApiProperty({ type: [DiscordInviteClaimDto] })
|
||||
items!: DiscordInviteClaimDto[];
|
||||
}
|
||||
|
||||
export class DiscordInviteBatchDto {
|
||||
@ApiProperty()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
guildId!: string;
|
||||
|
||||
@ApiProperty()
|
||||
channelId!: string;
|
||||
|
||||
@ApiProperty({ type: 'string', required: false, nullable: true })
|
||||
messageId!: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
maxClaims!: number;
|
||||
|
||||
@ApiProperty()
|
||||
claimed!: number;
|
||||
|
||||
@ApiProperty()
|
||||
used!: number;
|
||||
|
||||
@ApiProperty()
|
||||
createdByDiscordUserId!: string;
|
||||
|
||||
@ApiProperty({ type: 'string', required: false, nullable: true })
|
||||
cancelledAt!: Date | null;
|
||||
|
||||
@ApiProperty({ type: 'string' })
|
||||
createdAt!: Date;
|
||||
}
|
||||
|
||||
export class DiscordInviteBatchListResponseDto {
|
||||
@ApiProperty({ type: [DiscordInviteBatchDto] })
|
||||
items!: DiscordInviteBatchDto[];
|
||||
}
|
||||
|
||||
export class DiscordInviteBatchCancelQueryDto {
|
||||
@ApiProperty({ required: false, enum: ['true', 'false'], description: 'Also delete the batch’s unredeemed claims' })
|
||||
@IsOptional()
|
||||
@IsIn(['true', 'false'])
|
||||
revokeUnused?: string;
|
||||
}
|
||||
|
||||
export class DiscordInviteBatchCancelResponseDto {
|
||||
@ApiProperty({ type: DiscordInviteBatchDto })
|
||||
batch!: DiscordInviteBatchDto;
|
||||
|
||||
@ApiProperty({ description: 'Unredeemed claims deleted by --revoke-unused' })
|
||||
revokedClaims!: number;
|
||||
}
|
||||
@@ -36,6 +36,9 @@ const schema = z.object({
|
||||
|
||||
WEB_BASE_URL: z.url().default('http://localhost:5173'),
|
||||
|
||||
FUTO_BACKUPS_BOT_URL: z.string().default(''),
|
||||
INTERNAL_SECRET: z.string().default(''),
|
||||
|
||||
OIDC_ADMIN_ISSUER: z.url().transform((url) => new URL(url)),
|
||||
OIDC_ADMIN_CLIENT_ID: z.string(),
|
||||
OIDC_ADMIN_CLIENT_SECRET: z.string(),
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Kysely, sql } from 'kysely';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { DB } from 'src/schema';
|
||||
|
||||
@Injectable()
|
||||
export class DiscordInviteRepository {
|
||||
constructor(@InjectKysely() private db: Kysely<DB>) {}
|
||||
|
||||
listClaims() {
|
||||
return this.db
|
||||
.selectFrom('userAllowlist')
|
||||
.selectAll()
|
||||
.where('discordUserId', 'is not', null)
|
||||
.orderBy('createdAt', 'desc')
|
||||
.execute();
|
||||
}
|
||||
|
||||
getClaim(discordUserId: string) {
|
||||
return this.db
|
||||
.selectFrom('userAllowlist')
|
||||
.selectAll()
|
||||
.where('discordUserId', '=', discordUserId)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
deleteClaim(id: string, discordUserId: string): Promise<'deleted' | 'linked' | 'used'> {
|
||||
return this.db.transaction().execute(async (trx) => {
|
||||
// Linking holds pg_advisory_xact_lock(hashtext(discordUserId)); taking it
|
||||
// here serializes revocation against an in-flight redemption, whose link
|
||||
// row lands before markUsed does.
|
||||
await sql`SELECT pg_advisory_xact_lock(hashtext(${discordUserId}))`.execute(trx);
|
||||
const link = await trx
|
||||
.selectFrom('discordLinks')
|
||||
.select('id')
|
||||
.where('discordUserId', '=', discordUserId)
|
||||
.executeTakeFirst();
|
||||
if (link) {
|
||||
return 'linked';
|
||||
}
|
||||
const deleted = await trx
|
||||
.deleteFrom('userAllowlist')
|
||||
.where('id', '=', sql<string>`${id}::uuid`)
|
||||
.where('inviteUsed', '=', false)
|
||||
.returning('id')
|
||||
.executeTakeFirst();
|
||||
return deleted === undefined ? 'used' : 'deleted';
|
||||
});
|
||||
}
|
||||
|
||||
listBatches() {
|
||||
return this.batchesWithCounts().execute();
|
||||
}
|
||||
|
||||
getBatch(id: string) {
|
||||
return this.batchesWithCounts()
|
||||
.where('discordInviteBatches.id', '=', sql<string>`${id}::uuid`)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
private batchesWithCounts() {
|
||||
return this.db
|
||||
.selectFrom('discordInviteBatches')
|
||||
.leftJoin('userAllowlist', 'userAllowlist.batchId', 'discordInviteBatches.id')
|
||||
.selectAll('discordInviteBatches')
|
||||
.select((eb) => [
|
||||
eb.fn.count<number>('userAllowlist.id').as('claimed'),
|
||||
eb.fn.count<number>('userAllowlist.id').filterWhere('userAllowlist.inviteUsed', '=', true).as('used'),
|
||||
])
|
||||
.groupBy('discordInviteBatches.id')
|
||||
.orderBy('discordInviteBatches.createdAt', 'desc');
|
||||
}
|
||||
|
||||
cancelBatch(id: string, revokeUnused: boolean): Promise<number> {
|
||||
return this.db.transaction().execute(async (trx) => {
|
||||
// Claim transactions hold pg_advisory_xact_lock(hashtext(batchId));
|
||||
// taking it here fences out in-flight claims so none can slip in after
|
||||
// the cancellation and unused-claim sweep.
|
||||
await sql`SELECT pg_advisory_xact_lock(hashtext(${id}))`.execute(trx);
|
||||
await trx
|
||||
.updateTable('discordInviteBatches')
|
||||
.set({ cancelledAt: new Date() })
|
||||
.where('id', '=', sql<string>`${id}::uuid`)
|
||||
.where('cancelledAt', 'is', null)
|
||||
.execute();
|
||||
if (!revokeUnused) {
|
||||
return 0;
|
||||
}
|
||||
const deleted = await trx
|
||||
.deleteFrom('userAllowlist')
|
||||
.where('batchId', '=', sql<string>`${id}::uuid`)
|
||||
.where('inviteUsed', '=', false)
|
||||
.where('discordUserId', 'is not', null)
|
||||
.returning('id')
|
||||
.execute();
|
||||
return deleted.length;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { env } from 'src/env';
|
||||
|
||||
@Injectable()
|
||||
export class FutoBackupsBotRepository {
|
||||
get enabled(): boolean {
|
||||
return Boolean(env.FUTO_BACKUPS_BOT_URL);
|
||||
}
|
||||
|
||||
async closeDrop(batchId: string, channelId: string, messageId: string): Promise<void> {
|
||||
const response = await fetch(new URL('/internal/drops/close', env.FUTO_BACKUPS_BOT_URL), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Internal-Secret': env.INTERNAL_SECRET,
|
||||
},
|
||||
body: JSON.stringify({ batchId, channelId, messageId }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`futo-backups-bot POST /internal/drops/close failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import { DiscordInviteService } from 'src/services/discordInvite.service';
|
||||
import { Mocks, newMocks } from '../../test/mocks';
|
||||
|
||||
const claim = {
|
||||
id: 'claim-1',
|
||||
email: null,
|
||||
inviteCode: 'code',
|
||||
invited: true,
|
||||
inviteUsed: false,
|
||||
inviteUsedAt: null,
|
||||
inviteEmailSentAt: null,
|
||||
discordUserId: '123456789',
|
||||
discordUsername: 'someone',
|
||||
batchId: 'batch-1',
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const batch = {
|
||||
id: 'batch-1',
|
||||
guildId: 'guild-1',
|
||||
channelId: 'channel-1',
|
||||
messageId: 'message-1',
|
||||
maxClaims: 10,
|
||||
createdByDiscordUserId: 'staff-1',
|
||||
cancelledAt: null,
|
||||
createdAt: new Date(),
|
||||
claimed: 3,
|
||||
used: 1,
|
||||
};
|
||||
|
||||
describe(DiscordInviteService.name, () => {
|
||||
let sut: DiscordInviteService;
|
||||
let mocks: Mocks;
|
||||
|
||||
beforeEach(() => {
|
||||
mocks = newMocks();
|
||||
sut = new DiscordInviteService(mocks.discordInvite as never, mocks.bot as never, mocks.logger as never);
|
||||
});
|
||||
|
||||
describe('revokeClaim', () => {
|
||||
it('deletes an unredeemed claim', async () => {
|
||||
mocks.discordInvite.getClaim.mockResolvedValue(claim);
|
||||
|
||||
await sut.revokeClaim('123456789');
|
||||
|
||||
expect(mocks.discordInvite.deleteClaim).toHaveBeenCalledWith('claim-1', '123456789');
|
||||
});
|
||||
|
||||
it('rejects an unknown claim', async () => {
|
||||
mocks.discordInvite.getClaim.mockResolvedValue(void 0);
|
||||
|
||||
await expect(sut.revokeClaim('123456789')).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('refuses a redeemed claim', async () => {
|
||||
mocks.discordInvite.getClaim.mockResolvedValue({ ...claim, inviteUsed: true });
|
||||
|
||||
await expect(sut.revokeClaim('123456789')).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(mocks.discordInvite.deleteClaim).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a claim redeemed between the read and the delete', async () => {
|
||||
mocks.discordInvite.getClaim.mockResolvedValue(claim);
|
||||
mocks.discordInvite.deleteClaim.mockResolvedValue('used');
|
||||
|
||||
await expect(sut.revokeClaim('123456789')).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('refuses a claim whose discord account got linked mid-redemption', async () => {
|
||||
mocks.discordInvite.getClaim.mockResolvedValue(claim);
|
||||
mocks.discordInvite.deleteClaim.mockResolvedValue('linked');
|
||||
|
||||
await expect(sut.revokeClaim('123456789')).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancelBatch', () => {
|
||||
beforeEach(() => {
|
||||
mocks.discordInvite.getBatch.mockResolvedValue(batch);
|
||||
});
|
||||
|
||||
it('soft-cancels and eagerly closes the drop message', async () => {
|
||||
await expect(sut.cancelBatch('batch-1', false)).resolves.toEqual(
|
||||
expect.objectContaining({ revokedClaims: 0, batch: expect.objectContaining({ claimed: 3, used: 1 }) }),
|
||||
);
|
||||
|
||||
expect(mocks.discordInvite.cancelBatch).toHaveBeenCalledWith('batch-1', false);
|
||||
expect(mocks.bot.closeDrop).toHaveBeenCalledWith('batch-1', 'channel-1', 'message-1');
|
||||
});
|
||||
|
||||
it('revokes unredeemed claims when asked', async () => {
|
||||
mocks.discordInvite.cancelBatch.mockResolvedValue(2);
|
||||
|
||||
await expect(sut.cancelBatch('batch-1', true)).resolves.toEqual(expect.objectContaining({ revokedClaims: 2 }));
|
||||
|
||||
expect(mocks.discordInvite.cancelBatch).toHaveBeenCalledWith('batch-1', true);
|
||||
});
|
||||
|
||||
it('survives a failed bot notification', async () => {
|
||||
mocks.bot.closeDrop.mockRejectedValue(new Error('bot down'));
|
||||
|
||||
await expect(sut.cancelBatch('batch-1', false)).resolves.toBeDefined();
|
||||
|
||||
expect(mocks.logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips the bot when the batch has no posted message', async () => {
|
||||
mocks.discordInvite.getBatch.mockResolvedValue({ ...batch, messageId: null });
|
||||
|
||||
await sut.cancelBatch('batch-1', false);
|
||||
|
||||
expect(mocks.bot.closeDrop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an unknown batch', async () => {
|
||||
mocks.discordInvite.getBatch.mockResolvedValue(void 0);
|
||||
|
||||
await expect(sut.cancelBatch('nope', false)).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { LoggerRepository } from '@common/server/otel';
|
||||
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
DiscordInviteBatchCancelResponseDto,
|
||||
DiscordInviteBatchDto,
|
||||
DiscordInviteBatchListResponseDto,
|
||||
DiscordInviteClaimDto,
|
||||
DiscordInviteClaimListResponseDto,
|
||||
} from 'src/dto/discordInvite.dto';
|
||||
import { DiscordInviteRepository } from 'src/repositories/discordInvite.repository';
|
||||
import { FutoBackupsBotRepository } from 'src/repositories/futoBackupsBot.repository';
|
||||
|
||||
@Injectable()
|
||||
export class DiscordInviteService {
|
||||
constructor(
|
||||
private readonly invites: DiscordInviteRepository,
|
||||
private readonly bot: FutoBackupsBotRepository,
|
||||
private readonly logger: LoggerRepository,
|
||||
) {}
|
||||
|
||||
async listClaims(): Promise<DiscordInviteClaimListResponseDto> {
|
||||
const claims = await this.invites.listClaims();
|
||||
return {
|
||||
items: claims.map(
|
||||
(claim): DiscordInviteClaimDto => ({
|
||||
id: claim.id,
|
||||
discordUserId: claim.discordUserId!,
|
||||
discordUsername: claim.discordUsername,
|
||||
batchId: claim.batchId,
|
||||
inviteUsed: claim.inviteUsed,
|
||||
inviteUsedAt: claim.inviteUsedAt,
|
||||
createdAt: claim.createdAt,
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async revokeClaim(discordUserId: string): Promise<void> {
|
||||
const claim = await this.invites.getClaim(discordUserId);
|
||||
if (!claim) {
|
||||
throw new NotFoundException(`No invite claim for Discord user ${discordUserId}`);
|
||||
}
|
||||
const result = claim.inviteUsed ? 'used' : await this.invites.deleteClaim(claim.id, discordUserId);
|
||||
switch (result) {
|
||||
case 'used': {
|
||||
throw new ConflictException('Invite already redeemed — manage the account (disable / unlink-discord) instead');
|
||||
}
|
||||
case 'linked': {
|
||||
throw new ConflictException(
|
||||
'Discord account is already linked — manage the account (disable / unlink-discord) instead',
|
||||
);
|
||||
}
|
||||
case 'deleted': {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async listBatches(): Promise<DiscordInviteBatchListResponseDto> {
|
||||
const batches = await this.invites.listBatches();
|
||||
return { items: batches.map((batch) => this.toBatchDto(batch)) };
|
||||
}
|
||||
|
||||
async cancelBatch(id: string, revokeUnused: boolean): Promise<DiscordInviteBatchCancelResponseDto> {
|
||||
const batch = await this.invites.getBatch(id);
|
||||
if (!batch) {
|
||||
throw new NotFoundException(`No invite batch with id ${id}`);
|
||||
}
|
||||
|
||||
const revokedClaims = await this.invites.cancelBatch(id, revokeUnused);
|
||||
|
||||
if (this.bot.enabled && batch.messageId) {
|
||||
await this.bot
|
||||
.closeDrop(batch.id, batch.channelId, batch.messageId)
|
||||
.catch((error: unknown) =>
|
||||
this.logger.warn(error, 'could not close the drop message — the bot disables it on the next click'),
|
||||
);
|
||||
}
|
||||
|
||||
const updated = await this.invites.getBatch(id);
|
||||
return { batch: this.toBatchDto(updated!), revokedClaims };
|
||||
}
|
||||
|
||||
private toBatchDto(batch: {
|
||||
id: string;
|
||||
guildId: string;
|
||||
channelId: string;
|
||||
messageId: string | null;
|
||||
maxClaims: number;
|
||||
createdByDiscordUserId: string;
|
||||
cancelledAt: Date | null;
|
||||
createdAt: Date;
|
||||
claimed: number | string | bigint;
|
||||
used: number | string | bigint;
|
||||
}): DiscordInviteBatchDto {
|
||||
return {
|
||||
id: batch.id,
|
||||
guildId: batch.guildId,
|
||||
channelId: batch.channelId,
|
||||
messageId: batch.messageId,
|
||||
maxClaims: batch.maxClaims,
|
||||
claimed: Number(batch.claimed),
|
||||
used: Number(batch.used),
|
||||
createdByDiscordUserId: batch.createdByDiscordUserId,
|
||||
cancelledAt: batch.cancelledAt,
|
||||
createdAt: batch.createdAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { EmailRepository } from '@common/server/email';
|
||||
import type { LoggerRepository, WideContextRepository } from '@common/server/otel';
|
||||
import type { DatabaseRepository } from 'src/repositories/database.repository';
|
||||
import type { DiscordInviteRepository } from 'src/repositories/discordInvite.repository';
|
||||
import type { DiscordLinkRepository } from 'src/repositories/discordLink.repository';
|
||||
import type { FutoBackupsBotRepository } from 'src/repositories/futoBackupsBot.repository';
|
||||
import type { OidcRepository } from 'src/repositories/oidc.repository';
|
||||
import type { SessionRepository } from 'src/repositories/session.repository';
|
||||
import type { UserRepository } from 'src/repositories/user.repository';
|
||||
@@ -9,6 +11,24 @@ import type { UserAllowlistRepository } from 'src/repositories/userAllowlist.rep
|
||||
|
||||
export type RepositoryInterface<T extends object> = Pick<T, keyof T>;
|
||||
|
||||
export const newDiscordInviteRepositoryMock = (): jest.Mocked<RepositoryInterface<DiscordInviteRepository>> => {
|
||||
return {
|
||||
listClaims: jest.fn().mockResolvedValue([]),
|
||||
getClaim: jest.fn(),
|
||||
deleteClaim: jest.fn().mockResolvedValue('deleted'),
|
||||
listBatches: jest.fn().mockResolvedValue([]),
|
||||
getBatch: jest.fn(),
|
||||
cancelBatch: jest.fn().mockResolvedValue(0),
|
||||
};
|
||||
};
|
||||
|
||||
export const newFutoBackupsBotRepositoryMock = (): jest.Mocked<RepositoryInterface<FutoBackupsBotRepository>> => {
|
||||
return {
|
||||
enabled: true,
|
||||
closeDrop: jest.fn().mockResolvedValue(void 0),
|
||||
};
|
||||
};
|
||||
|
||||
export const newDatabaseRepositoryMock = (): jest.Mocked<RepositoryInterface<DatabaseRepository>> => {
|
||||
return {
|
||||
shutdown: jest.fn(),
|
||||
@@ -94,6 +114,8 @@ export const newMetricServiceMock = () => ({
|
||||
|
||||
export const newMocks = () => {
|
||||
return {
|
||||
discordInvite: newDiscordInviteRepositoryMock(),
|
||||
bot: newFutoBackupsBotRepositoryMock(),
|
||||
database: newDatabaseRepositoryMock(),
|
||||
discordLink: newDiscordLinkRepositoryMock(),
|
||||
user: newUserRepositoryMock(),
|
||||
|
||||
@@ -7,7 +7,7 @@ import { DiscordLinkRequestTable } from 'src/schema/tables/discordLinkRequest.ta
|
||||
import { UserAllowlistTable } from 'src/schema/tables/userAllowlist.table';
|
||||
|
||||
export type InviteClaimResult =
|
||||
| { status: 'linked' | 'used' | 'unknownBatch' | 'exhausted' }
|
||||
| { status: 'linked' | 'used' | 'unknownBatch' | 'exhausted' | 'cancelled' }
|
||||
| { status: 'ok'; entry: Selectable<UserAllowlistTable>; remaining: number | null };
|
||||
|
||||
@Injectable()
|
||||
@@ -109,7 +109,12 @@ export class DiscordRepository {
|
||||
return updated !== undefined;
|
||||
}
|
||||
|
||||
claimInvite(discordUserId: string, batchId: string | null, inviteCode: string): Promise<InviteClaimResult> {
|
||||
claimInvite(
|
||||
discordUserId: string,
|
||||
discordUsername: string,
|
||||
batchId: string | null,
|
||||
inviteCode: string,
|
||||
): Promise<InviteClaimResult> {
|
||||
return this.db.transaction().execute(async (trx): Promise<InviteClaimResult> => {
|
||||
const lockKey = batchId ?? discordUserId;
|
||||
await sql`SELECT pg_advisory_xact_lock(least(hashtext(${discordUserId}), hashtext(${lockKey}))),
|
||||
@@ -143,6 +148,9 @@ export class DiscordRepository {
|
||||
if (!batch) {
|
||||
return { status: 'unknownBatch' };
|
||||
}
|
||||
if (batch.cancelledAt) {
|
||||
return { status: 'cancelled' };
|
||||
}
|
||||
const { claimed } = await trx
|
||||
.selectFrom('userAllowlist')
|
||||
.select((eb) => eb.fn.countAll<number>().as('claimed'))
|
||||
@@ -156,7 +164,7 @@ export class DiscordRepository {
|
||||
|
||||
const entry = await trx
|
||||
.insertInto('userAllowlist')
|
||||
.values({ inviteCode, invited: true, discordUserId, batchId })
|
||||
.values({ inviteCode, invited: true, discordUserId, discordUsername, batchId })
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
return { status: 'ok', entry, remaining };
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "discordInviteBatches" ADD "cancelledAt" timestamp with time zone;`.execute(db);
|
||||
await sql`ALTER TABLE "userAllowlist" ADD "discordUsername" character varying;`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "userAllowlist" DROP COLUMN "discordUsername";`.execute(db);
|
||||
await sql`ALTER TABLE "discordInviteBatches" DROP COLUMN "cancelledAt";`.execute(db);
|
||||
}
|
||||
@@ -20,6 +20,9 @@ export class DiscordInviteBatchTable {
|
||||
@Column()
|
||||
createdByDiscordUserId!: string;
|
||||
|
||||
@Column({ type: 'timestamp with time zone', nullable: true })
|
||||
cancelledAt!: Date | null;
|
||||
|
||||
@Column({ type: 'timestamp with time zone', default: () => 'now()' })
|
||||
createdAt!: Generated<Date>;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,9 @@ export class UserAllowlistTable {
|
||||
@Column({ unique: true, nullable: true })
|
||||
discordUserId!: string | null;
|
||||
|
||||
@Column({ nullable: true })
|
||||
discordUsername!: string | null;
|
||||
|
||||
@ForeignKeyColumn(() => DiscordInviteBatchTable, {
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'SET NULL',
|
||||
|
||||
@@ -29,7 +29,7 @@ describe(AuthService.name, () => {
|
||||
beforeEach(() => {
|
||||
mocks = newMocks();
|
||||
sut = new AuthService(
|
||||
mocks.jwt as never,
|
||||
mocks.logger as never,
|
||||
mocks.oidc as never,
|
||||
mocks.user as never,
|
||||
mocks.userAllowlist as never,
|
||||
@@ -481,6 +481,7 @@ describe(AuthService.name, () => {
|
||||
|
||||
it('should allow a new user with a valid discord invite, link the account, and mark the claim used', async () => {
|
||||
mocks.discord.consumeInviteRequest.mockResolvedValue(inviteRequest);
|
||||
mocks.userAllowlist.markUsed.mockResolvedValue({ id: 'entry' } as never);
|
||||
|
||||
await expect(sut.getOrCreateUser(claims, undefined, 'token')).resolves.toBe(mockUser);
|
||||
|
||||
@@ -500,9 +501,19 @@ describe(AuthService.name, () => {
|
||||
expect(mocks.user.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should warn instead of failing when the claim was revoked mid-redemption', async () => {
|
||||
mocks.discord.consumeInviteRequest.mockResolvedValue(inviteRequest);
|
||||
mocks.userAllowlist.markUsed.mockResolvedValue(void 0);
|
||||
|
||||
await expect(sut.getOrCreateUser(claims, undefined, 'token')).resolves.toBe(mockUser);
|
||||
|
||||
expect(mocks.logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should link an existing user who redeems a discord invite', async () => {
|
||||
mocks.user.getBySub.mockResolvedValue(mockUser);
|
||||
mocks.discord.consumeInviteRequest.mockResolvedValue(inviteRequest);
|
||||
mocks.userAllowlist.markUsed.mockResolvedValue({ id: 'entry' } as never);
|
||||
|
||||
await expect(sut.getOrCreateUser(claims, undefined, 'token')).resolves.toEqual(mockUser);
|
||||
|
||||
|
||||
@@ -171,7 +171,12 @@ export class AuthService {
|
||||
|
||||
if (invite?.allowlistId) {
|
||||
await this.discord.linkDirect(user.id, invite.discordUserId, invite.discordUsername);
|
||||
await this.allowlist.markUsed(invite.allowlistId);
|
||||
if (!(await this.allowlist.markUsed(invite.allowlistId))) {
|
||||
this.logger.warn(
|
||||
{ userId: user.id, discordUserId: invite.discordUserId },
|
||||
'invite claim was revoked mid-redemption — the account exists without a claim record',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return user;
|
||||
|
||||
@@ -130,7 +130,7 @@ describe(DiscordService.name, () => {
|
||||
sut.createInvite({ discordUserId: '123456789', discordUsername: 'someone', batchId: 'batch' }),
|
||||
).resolves.toEqual({ code: 'token', expiresAt: request.expiresAt, remaining: 2 });
|
||||
|
||||
expect(mocks.discord.claimInvite).toHaveBeenCalledWith('123456789', 'batch', 'invite-code');
|
||||
expect(mocks.discord.claimInvite).toHaveBeenCalledWith('123456789', 'someone', 'batch', 'invite-code');
|
||||
expect(mocks.discord.createRequest).toHaveBeenCalledWith(expect.objectContaining({ allowlistId: 'entry' }));
|
||||
});
|
||||
|
||||
@@ -148,6 +148,7 @@ describe(DiscordService.name, () => {
|
||||
['linked', 'ALREADY_LINKED'],
|
||||
['used', 'INVITE_USED'],
|
||||
['exhausted', 'BATCH_EXHAUSTED'],
|
||||
['cancelled', 'BATCH_CANCELLED'],
|
||||
] as const)('rejects a %s claim with a conflict', async (status, message) => {
|
||||
mocks.discord.claimInvite.mockResolvedValue({ status });
|
||||
|
||||
|
||||
@@ -91,7 +91,12 @@ export class DiscordService {
|
||||
|
||||
async createInvite(dto: DiscordInviteCreateDto): Promise<DiscordInviteCreatedDto> {
|
||||
await this.discord.deleteExpiredRequests();
|
||||
const claim = await this.discord.claimInvite(dto.discordUserId, dto.batchId ?? null, this.crypto.randomHex(16));
|
||||
const claim = await this.discord.claimInvite(
|
||||
dto.discordUserId,
|
||||
dto.discordUsername,
|
||||
dto.batchId ?? null,
|
||||
this.crypto.randomHex(16),
|
||||
);
|
||||
switch (claim.status) {
|
||||
case 'linked': {
|
||||
throw new ConflictException('ALREADY_LINKED');
|
||||
@@ -102,6 +107,9 @@ export class DiscordService {
|
||||
case 'exhausted': {
|
||||
throw new ConflictException('BATCH_EXHAUSTED');
|
||||
}
|
||||
case 'cancelled': {
|
||||
throw new ConflictException('BATCH_CANCELLED');
|
||||
}
|
||||
case 'unknownBatch': {
|
||||
throw new NotFoundException(`No invite batch with id ${dto.batchId}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package adminapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// InviteClaim mirrors the admin-api DiscordInviteClaimDto.
|
||||
type InviteClaim struct {
|
||||
ID string `json:"id"`
|
||||
DiscordUserID string `json:"discordUserId"`
|
||||
DiscordUsername *string `json:"discordUsername"`
|
||||
BatchID *string `json:"batchId"`
|
||||
InviteUsed bool `json:"inviteUsed"`
|
||||
InviteUsedAt *string `json:"inviteUsedAt"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
// InviteBatch mirrors the admin-api DiscordInviteBatchDto.
|
||||
type InviteBatch struct {
|
||||
ID string `json:"id"`
|
||||
GuildID string `json:"guildId"`
|
||||
ChannelID string `json:"channelId"`
|
||||
MessageID *string `json:"messageId"`
|
||||
MaxClaims int `json:"maxClaims"`
|
||||
Claimed int `json:"claimed"`
|
||||
Used int `json:"used"`
|
||||
CreatedByDiscordUserID string `json:"createdByDiscordUserId"`
|
||||
CancelledAt *string `json:"cancelledAt"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
// InviteBatchCancelResult is the DELETE /api/discord-invites/batches/:id envelope.
|
||||
type InviteBatchCancelResult struct {
|
||||
Batch InviteBatch `json:"batch"`
|
||||
RevokedClaims int `json:"revokedClaims"`
|
||||
}
|
||||
|
||||
// ListInviteClaims returns every Discord-minted beta invite claim.
|
||||
func (c *Client) ListInviteClaims(ctx context.Context) ([]InviteClaim, error) {
|
||||
var out struct {
|
||||
Items []InviteClaim `json:"items"`
|
||||
}
|
||||
if err := c.getJSON(ctx, "/api/discord-invites", nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.Items, nil
|
||||
}
|
||||
|
||||
// ListInviteBatches returns every invite drop with its claim counts.
|
||||
func (c *Client) ListInviteBatches(ctx context.Context) ([]InviteBatch, error) {
|
||||
var out struct {
|
||||
Items []InviteBatch `json:"items"`
|
||||
}
|
||||
if err := c.getJSON(ctx, "/api/discord-invites/batches", nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.Items, nil
|
||||
}
|
||||
|
||||
// RevokeInviteClaim deletes an unredeemed claim; the admin-api refuses claims
|
||||
// that were already turned into accounts.
|
||||
func (c *Client) RevokeInviteClaim(ctx context.Context, discordID string) error {
|
||||
return c.deleteWithMessage(ctx, "/api/discord-invites/"+url.PathEscape(discordID), nil)
|
||||
}
|
||||
|
||||
// CancelInviteBatch soft-cancels a drop (no further claims) and optionally
|
||||
// deletes its unredeemed claims.
|
||||
func (c *Client) CancelInviteBatch(ctx context.Context, batchID string, revokeUnused bool) (*InviteBatchCancelResult, error) {
|
||||
path := "/api/discord-invites/batches/" + url.PathEscape(batchID)
|
||||
if revokeUnused {
|
||||
path += "?revokeUnused=true"
|
||||
}
|
||||
var out InviteBatchCancelResult
|
||||
if err := c.deleteWithMessage(ctx, path, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// deleteWithMessage issues a DELETE, decodes an optional JSON response into
|
||||
// out, and surfaces the admin-api's error message on failure.
|
||||
func (c *Client) deleteWithMessage(ctx context.Context, path string, out any) error {
|
||||
u := c.baseURL + path
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, u, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
c.setAuth(req)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("DELETE %s: %w", u, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||
return fmt.Errorf("admin-api rejected the session token (status %d) — run `yuctl login --reauth`", resp.StatusCode)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
var body struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err == nil && body.Message != "" {
|
||||
return fmt.Errorf("DELETE %s: status %d: %s", u, resp.StatusCode, body.Message)
|
||||
}
|
||||
return fmt.Errorf("DELETE %s: status %d", u, resp.StatusCode)
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
return fmt.Errorf("parse response: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
// Package invites manages Discord closed-beta invite claims and drops via the
|
||||
// partition's yucca-admin-api.
|
||||
package invites
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"yuctl/cmdutil"
|
||||
)
|
||||
|
||||
func New(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "invites",
|
||||
Short: "Discord closed-beta invite administration via yucca-admin-api",
|
||||
}
|
||||
cmd.AddCommand(
|
||||
newListCmd(f),
|
||||
newBatchesCmd(f),
|
||||
newRevokeCmd(f),
|
||||
newCancelCmd(f),
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newListCmd(f *cmdutil.Factory) *cobra.Command {
|
||||
admin := &cmdutil.AdminFlags{}
|
||||
c := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List Discord beta-invite claims",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
ctx := cmd.Context()
|
||||
client, partition, err := admin.Client(ctx, f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
claims, err := client.ListInviteClaims(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(f.IO.Out, 0, 2, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "DISCORD ID\tUSERNAME\tBATCH\tREDEEMED\tCLAIMED AT")
|
||||
for _, claim := range claims {
|
||||
username := ""
|
||||
if claim.DiscordUsername != nil {
|
||||
username = *claim.DiscordUsername
|
||||
}
|
||||
batch := ""
|
||||
if claim.BatchID != nil {
|
||||
batch = *claim.BatchID
|
||||
}
|
||||
redeemed := "no"
|
||||
if claim.InviteUsed {
|
||||
redeemed = "yes"
|
||||
if claim.InviteUsedAt != nil {
|
||||
redeemed = *claim.InviteUsedAt
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", claim.DiscordUserID, username, batch, redeemed, claim.CreatedAt)
|
||||
}
|
||||
w.Flush()
|
||||
fmt.Fprintf(f.IO.Err, "\n%d claim(s) in partition %s\n", len(claims), partition)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
admin.Register(c)
|
||||
return c
|
||||
}
|
||||
|
||||
func newBatchesCmd(f *cmdutil.Factory) *cobra.Command {
|
||||
admin := &cmdutil.AdminFlags{}
|
||||
c := &cobra.Command{
|
||||
Use: "batches",
|
||||
Short: "List invite drops with their claim counts",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
ctx := cmd.Context()
|
||||
client, partition, err := admin.Client(ctx, f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
batches, err := client.ListInviteBatches(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(f.IO.Out, 0, 2, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "ID\tCHANNEL\tCLAIMED\tREDEEMED\tCREATED BY\tCREATED AT\tCANCELLED")
|
||||
for _, batch := range batches {
|
||||
cancelled := ""
|
||||
if batch.CancelledAt != nil {
|
||||
cancelled = *batch.CancelledAt
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%d/%d\t%d\t%s\t%s\t%s\n",
|
||||
batch.ID, batch.ChannelID, batch.Claimed, batch.MaxClaims, batch.Used,
|
||||
batch.CreatedByDiscordUserID, batch.CreatedAt, cancelled)
|
||||
}
|
||||
w.Flush()
|
||||
fmt.Fprintf(f.IO.Err, "\n%d batch(es) in partition %s\n", len(batches), partition)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
admin.Register(c)
|
||||
return c
|
||||
}
|
||||
|
||||
func newRevokeCmd(f *cmdutil.Factory) *cobra.Command {
|
||||
admin := &cmdutil.AdminFlags{}
|
||||
var discordID string
|
||||
c := &cobra.Command{
|
||||
Use: "revoke",
|
||||
Short: "Revoke an unredeemed beta-invite claim",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
client, _, err := admin.Client(cmd.Context(), f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := client.RevokeInviteClaim(cmd.Context(), discordID); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(f.IO.Out, "revoked the invite claim of discord user %s\n", discordID)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
c.Flags().StringVar(&discordID, "discord-id", "", "discord user id (snowflake)")
|
||||
_ = c.MarkFlagRequired("discord-id")
|
||||
admin.Register(c)
|
||||
return c
|
||||
}
|
||||
|
||||
func newCancelCmd(f *cmdutil.Factory) *cobra.Command {
|
||||
admin := &cmdutil.AdminFlags{}
|
||||
var revokeUnused bool
|
||||
c := &cobra.Command{
|
||||
Use: "cancel <batch-id>",
|
||||
Short: "Cancel an invite drop (stops further claims and disables its button)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, _, err := admin.Client(cmd.Context(), f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := client.CancelInviteBatch(cmd.Context(), args[0], revokeUnused)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(f.IO.Out, "cancelled batch %s (%d/%d claimed, %d redeemed)\n",
|
||||
result.Batch.ID, result.Batch.Claimed, result.Batch.MaxClaims, result.Batch.Used)
|
||||
if revokeUnused {
|
||||
fmt.Fprintf(f.IO.Out, "revoked %d unredeemed claim(s)\n", result.RevokedClaims)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
c.Flags().BoolVar(&revokeUnused, "revoke-unused", false, "also delete the batch's unredeemed claims")
|
||||
admin.Register(c)
|
||||
return c
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
configcmd "yuctl/cli/config"
|
||||
featurescmd "yuctl/cli/features"
|
||||
infracmd "yuctl/cli/infra"
|
||||
invitescmd "yuctl/cli/invites"
|
||||
toolscmd "yuctl/cli/tools"
|
||||
userscmd "yuctl/cli/users"
|
||||
"yuctl/cmdutil"
|
||||
@@ -58,6 +59,7 @@ func NewRootCmd() *cobra.Command {
|
||||
cephcmd.New(f),
|
||||
infracmd.New(f),
|
||||
userscmd.New(f),
|
||||
invitescmd.New(f),
|
||||
configcmd.New(f),
|
||||
featurescmd.New(f),
|
||||
toolscmd.New(f),
|
||||
|
||||
Reference in New Issue
Block a user