feat(internal-admin): brand reassignment — Phase F
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
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:
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user