Skip to content
Open
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
8 changes: 4 additions & 4 deletions backend/src/admin/admin.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -490,11 +490,11 @@ export class AdminService {
// 4. Refund the user's pending shop orders so they drop out of the
// fulfilment queue. refundOrder restocks the item and cascade-deletes
// the order's fulfilment updates; already-fulfilled orders are left as-is.
// Orders that already have an HCB card grant are excluded: refundOrder
// rejects them (the grant link must be reconciled in HCB by hand), and
// we must not let one such order abort the whole ban flow.
// Orders that already have an HCB card grant or SILO grant are excluded:
// refundOrder rejects them (the grant link must be reconciled by hand),
// and we must not let one such order abort the whole ban flow.
const pendingOrders = await this.orderRepo.find({
where: { userId, status: 'pending', hcbCardGrantId: IsNull() },
where: { userId, status: 'pending', hcbCardGrantId: IsNull(), siloGrantId: IsNull() },
select: ['id'],
});
for (const order of pendingOrders) {
Expand Down
2 changes: 2 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { DevlogsModule } from './devlogs/devlogs.module';
import { FraudReviewModule } from './fraud-review/fraud-review.module';
import { LapseModule } from './lapse/lapse.module';
import { HcbModule } from './hcb/hcb.module';
import { SiloModule } from './silo/silo.module';
import { User } from './entities/user.entity';
import { Session } from './entities/session.entity';
import { Project } from './entities/project.entity';
Expand Down Expand Up @@ -64,6 +65,7 @@ import { HealthController } from './health.controller';
FraudReviewModule,
LapseModule,
HcbModule,
SiloModule,
],
})
export class AppModule {}
1 change: 1 addition & 0 deletions backend/src/entities/audit-log.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export const AUDIT_ACTIONS = [
'devlog_deleted',
'hcb_connected',
'card_grant_issued',
'silo_grant_issued',
'admin_shop_item_change',
'admin_event_change',
'admin_fulfillment_message',
Expand Down
5 changes: 5 additions & 0 deletions backend/src/entities/order.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ export class Order {
@Column({ name: 'hcb_card_grant_id', type: 'varchar', length: 64, nullable: true })
hcbCardGrantId: string | null;

// SILO grant ID returned by the SILO API for this order, if any.
// Acts as a per-order idempotency lock: a non-null value blocks re-granting.
@Column({ name: 'silo_grant_id', type: 'varchar', length: 64, nullable: true })
siloGrantId: string | null;

@CreateDateColumn({ name: 'created_at' })
createdAt: Date;

Expand Down
15 changes: 15 additions & 0 deletions backend/src/migrations/1780000000000-AddSiloGrantId.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { MigrationInterface, QueryRunner } from "typeorm";

export class AddSiloGrantId1780000000000 implements MigrationInterface {
name = 'AddSiloGrantId1780000000000'

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "orders" ADD "silo_grant_id" varchar(64)`);
await queryRunner.query(`CREATE UNIQUE INDEX "UQ_orders_silo_grant_id" ON "orders"("silo_grant_id") WHERE "silo_grant_id" IS NOT NULL`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "UQ_orders_silo_grant_id"`);
await queryRunner.query(`ALTER TABLE "orders" DROP COLUMN "silo_grant_id"`);
}
}
15 changes: 15 additions & 0 deletions backend/src/shop/shop.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,7 @@ export class ShopService {
'order.pipesSpent',
'order.status',
'order.hcbCardGrantId',
'order.siloGrantId',
'order.createdAt',
'order.updatedAt',
'user.id',
Expand Down Expand Up @@ -431,6 +432,7 @@ export class ShopService {
pipesSpent: o.pipesSpent,
status: o.status,
hcbCardGrantId: o.hcbCardGrantId ?? null,
siloGrantId: o.siloGrantId ?? null,
createdAt: o.createdAt,
updatedAt: o.updatedAt,
userName: o.user?.nickname || o.user?.name || 'Unknown',
Expand Down Expand Up @@ -615,6 +617,12 @@ export class ShopService {
'already been issued for this order. Reconcile it in HCB instead.',
);
}
if (order.siloGrantId) {
throw new ConflictException(
`Cannot refund: a SILO grant (${order.siloGrantId}) has ` +
'already been issued for this order. Reconcile it in SILO instead.',
);
}

const user = await manager.findOne(User, {
where: { id: order.userId },
Expand Down Expand Up @@ -712,6 +720,13 @@ export class ShopService {
`(${granted.hcbCardGrantId}). Reconcile it in HCB instead.`,
);
}
const siloGranted = [target, ...others].find((o) => o.siloGrantId);
if (siloGranted) {
throw new ConflictException(
`Cannot merge: order ${siloGranted.id} has a SILO grant ` +
`(${siloGranted.siloGrantId}). Reconcile it in SILO instead.`,
);
}

let addedQty = 0;
let addedPipes = 0;
Expand Down
51 changes: 51 additions & 0 deletions backend/src/silo/silo.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import {
Body,
Controller,
Get,
Post,
Param,
ParseUUIDPipe,
Req,
UseGuards,
BadRequestException,
} from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import type { Request } from 'express';
import { SuperAdminGuard } from '../admin/super-admin.guard';
import { SiloService, type GrantAdmin } from './silo.service';

@Controller('api/admin/silo')
@UseGuards(SuperAdminGuard)
export class SiloController {
constructor(private readonly siloService: SiloService) {}

private admin(req: Request): GrantAdmin {
const user = (req as any).user;
const uid = user?.uid as string | undefined;
const email = user?.email as string | undefined;
if (!uid || !email) throw new BadRequestException('Not authenticated');
return { uid, email };
}

@Get('status')
status() {
return { configured: this.siloService.isConfigured };
}

@Get('prefill/:orderId')
prefill(@Param('orderId', ParseUUIDPipe) orderId: string) {
return this.siloService.buildPrefill(orderId);
}

@Throttle({ default: { limit: 20, ttl: 60000 } })
@Post('grant')
async createGrant(
@Req() req: Request,
@Body('orderId') orderId?: string,
) {
if (!orderId || typeof orderId !== 'string') {
throw new BadRequestException('orderId is required');
}
return this.siloService.createSiloGrantForOrder(orderId, this.admin(req));
}
}
22 changes: 22 additions & 0 deletions backend/src/silo/silo.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthModule } from '../auth/auth.module';
import { RsvpModule } from '../rsvp/rsvp.module';
import { AuditLogModule } from '../audit-log/audit-log.module';
import { Order } from '../entities/order.entity';
import { SiloService } from './silo.service';
import { SiloController } from './silo.controller';
import { SuperAdminGuard } from '../admin/super-admin.guard';

@Module({
imports: [
AuthModule,
RsvpModule,
AuditLogModule,
TypeOrmModule.forFeature([Order]),
],
controllers: [SiloController],
providers: [SiloService, SuperAdminGuard],
exports: [SiloService],
})
export class SiloModule {}
Loading