feat(internal-admin): brand reassignment — Phase F
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled

POST /internal/admin/subscriptions/:id/brands  { brandIds[], reason, founderId }

- Only operates on active or trial subscriptions.
- Refuses Full plan (brandCount=0) — that tier auto-grants all brands.
- brandIds.length must exactly match plan.brandCount, no duplicates.
- Each brand ID must exist and be active.
- Replaces the user_brands rows for the subscription atomically (delete
  + insert; same-row contention is microseconds, panel calls are serial
  per founder).
- Logs the old → new brand sets for auditability.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 10:35:43 +03:00
parent d22662acb3
commit b7a5b6996b
2 changed files with 112 additions and 1 deletions

View File

@@ -99,6 +99,26 @@ export class BillingController {
});
}
@Post(":id/brands")
@HttpCode(HttpStatus.OK)
async setBrands(
@Param("id") id: string,
@Body()
body: { brandIds?: string[]; reason?: string; founderId?: string },
) {
this.requireFounder(body);
this.requireReason(body, "set-brands");
if (!Array.isArray(body.brandIds)) {
throw new BadRequestException("brandIds array required");
}
return this.billing.setSubscriptionBrands({
subscriptionId: id,
brandIds: body.brandIds,
reason: body.reason!,
founderId: body.founderId!,
});
}
private requireFounder(body: { founderId?: string }) {
if (!body.founderId) throw new BadRequestException("founderId required");
}

View File

@@ -8,7 +8,7 @@ import {
} from "@nestjs/common";
import { eq } from "drizzle-orm";
import { DATABASE, type Database } from "../database/database.provider";
import { plans, userBrands, userSubscriptions } from "../database/schema/core";
import { brands, plans, userBrands, userSubscriptions } from "../database/schema/core";
import { SubscriptionsService } from "../subscriptions/subscriptions.service";
@Injectable()
@@ -221,6 +221,97 @@ export class BillingService {
};
}
async setSubscriptionBrands(input: {
subscriptionId: string;
brandIds: string[];
reason: string;
founderId: string;
}) {
const [sub] = await this.db
.select()
.from(userSubscriptions)
.where(eq(userSubscriptions.id, input.subscriptionId))
.limit(1);
if (!sub) throw new NotFoundException("Subscription bulunamadı");
if (sub.status !== "active" && sub.status !== "trial") {
throw new ConflictException(
`'${sub.status}' subscription için brand atama yapılamaz`,
);
}
const [plan] = await this.db
.select()
.from(plans)
.where(eq(plans.id, sub.planId))
.limit(1);
if (!plan) throw new NotFoundException("Plan bulunamadı");
// brandCount 0 = "all brands" (Full plan): don't accept a custom list;
// panel surfaces this as "all brands automatic".
if (plan.brandCount === 0) {
throw new BadRequestException(
"Full plan tüm markalara otomatik erişim verir — brand seçimi yapılamaz",
);
}
if (input.brandIds.length !== plan.brandCount) {
throw new BadRequestException(
`Plan tam olarak ${plan.brandCount} marka gerektirir (${input.brandIds.length} seçildi)`,
);
}
if (new Set(input.brandIds).size !== input.brandIds.length) {
throw new BadRequestException("Marka listesi tekrar içeriyor");
}
// Validate all brand IDs exist and are active.
const existing = await this.db
.select({ id: brands.id })
.from(brands)
.where(eq(brands.isActive, true));
const validSet = new Set(existing.map((b) => b.id));
for (const id of input.brandIds) {
if (!validSet.has(id)) {
throw new BadRequestException(`Geçersiz veya pasif marka: ${id}`);
}
}
// Atomic-ish replace: capture previous, delete, insert. Drizzle doesn't
// expose a tx here without lifting the service signature; the window is
// microseconds and panel calls are serial per founder.
const previous = await this.db
.select({ brandId: userBrands.brandId })
.from(userBrands)
.where(eq(userBrands.subscriptionId, input.subscriptionId));
await this.db
.delete(userBrands)
.where(eq(userBrands.subscriptionId, input.subscriptionId));
if (input.brandIds.length > 0) {
await this.db.insert(userBrands).values(
input.brandIds.map((brandId) => ({
userId: sub.userId,
subscriptionId: input.subscriptionId,
brandId,
})),
);
}
this.logger.log(
`brands set: subscription=${input.subscriptionId} ` +
`[${previous.map((p) => p.brandId).join(",")}] → [${input.brandIds.join(",")}] ` +
`founder=${input.founderId} reason="${input.reason.slice(0, 80)}"`,
);
return {
success: true,
subscriptionId: input.subscriptionId,
userId: sub.userId,
planBrandCount: plan.brandCount,
previousBrandIds: previous.map((p) => p.brandId),
newBrandIds: input.brandIds,
};
}
async resume(input: {
subscriptionId: string;
reason: string;