mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
feat(auth): add Gilden (guilds) shared Mana pool system
Replace removed B2B org credit system with consumer-friendly shared Mana pools. Members spend directly from a guild pool managed by the Gildenmeister (owner). Supports funding from personal balance, per-member spending limits, and credit source routing. New endpoints: /gilden/* (guild CRUD) and /credits/guild/* (pool ops). POST /credits/use now accepts optional creditSource for guild routing. Delete broken b2b-journey E2E tests that tested phantom endpoints. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
63376c1313
commit
17df7b32f5
19 changed files with 1900 additions and 969 deletions
|
|
@ -124,7 +124,8 @@ export class CreditClientService {
|
|||
operation: string,
|
||||
amount: number,
|
||||
description: string,
|
||||
metadata?: Record<string, any>
|
||||
metadata?: Record<string, any>,
|
||||
creditSource?: { type: 'personal' } | { type: 'guild'; guildId: string }
|
||||
): Promise<boolean> {
|
||||
const authUrl = this.getAuthUrl();
|
||||
const serviceKey = this.getServiceKey();
|
||||
|
|
@ -151,6 +152,7 @@ export class CreditClientService {
|
|||
operation,
|
||||
...metadata,
|
||||
},
|
||||
...(creditSource && { creditSource }),
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { AuthModule } from './auth/auth.module';
|
|||
import { CreditsModule } from './credits/credits.module';
|
||||
import { FeedbackModule } from './feedback/feedback.module';
|
||||
import { GiftsModule } from './gifts/gifts.module';
|
||||
import { GuildsModule } from './guilds/guilds.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { SettingsModule } from './settings/settings.module';
|
||||
import { StorageModule } from './storage/storage.module';
|
||||
|
|
@ -57,6 +58,7 @@ import { SecurityModule } from './security';
|
|||
CreditsModule,
|
||||
FeedbackModule,
|
||||
GiftsModule,
|
||||
GuildsModule,
|
||||
HealthModule,
|
||||
SettingsModule,
|
||||
StorageModule,
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import { createBetterAuth } from '../better-auth.config';
|
|||
import type { BetterAuthInstance } from '../better-auth.config';
|
||||
import { getDb } from '../../db/connection';
|
||||
import { balances } from '../../db/schema/credits.schema';
|
||||
import { guildPools } from '../../db/schema/guilds.schema';
|
||||
import { GiftCodeService } from '../../gifts/services/gift-code.service';
|
||||
import { hasUser, hasToken, hasMember, hasMembers, hasSession } from '../types/better-auth.types';
|
||||
import { sourceAppStore } from '../stores/source-app.store';
|
||||
|
|
@ -241,6 +242,9 @@ export class BetterAuthService {
|
|||
// Step 3: Create owner's personal balance (for when they use credits)
|
||||
await this.createPersonalCreditBalance(ownerId);
|
||||
|
||||
// Step 4: Initialize guild pool for the organization
|
||||
await this.initializeGuildPool(organizationId);
|
||||
|
||||
return {
|
||||
user,
|
||||
organization: orgResult,
|
||||
|
|
@ -254,6 +258,30 @@ export class BetterAuthService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an organization directly (for guild creation).
|
||||
* The authenticated user becomes the owner.
|
||||
*/
|
||||
async createOrganizationDirect(
|
||||
token: string,
|
||||
data: { name: string; slug?: string; logo?: string }
|
||||
): Promise<CreateOrganizationResponse> {
|
||||
const slug = data.slug || this.slugify(data.name);
|
||||
|
||||
const orgResult = (await this.auth.api.createOrganization({
|
||||
body: {
|
||||
name: data.name,
|
||||
slug,
|
||||
...(data.logo && { logo: data.logo }),
|
||||
},
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
})) as CreateOrganizationResponse;
|
||||
|
||||
return orgResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite employee to organization
|
||||
*
|
||||
|
|
@ -1744,6 +1772,25 @@ export class BetterAuthService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a guild pool for an organization.
|
||||
* Non-critical — if it fails, the pool can be created later.
|
||||
*/
|
||||
private async initializeGuildPool(organizationId: string) {
|
||||
const db = getDb(this.databaseUrl);
|
||||
|
||||
try {
|
||||
await db.insert(guildPools).values({
|
||||
organizationId,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn('Failed to initialize guild pool (non-critical)', {
|
||||
organizationId,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to create URL-safe slugs
|
||||
*
|
||||
|
|
|
|||
|
|
@ -27,8 +27,10 @@ export class CreditsController {
|
|||
}
|
||||
|
||||
@Post('use')
|
||||
@ApiOperation({ summary: 'Use credits (personal or guild pool)' })
|
||||
@ApiResponse({ status: 200, description: 'Credits used successfully' })
|
||||
async useCredits(@CurrentUser() user: CurrentUserData, @Body() useCreditsDto: UseCreditsDto) {
|
||||
return this.creditsService.useCredits(user.userId, useCreditsDto);
|
||||
return this.creditsService.useCreditsWithSource(user.userId, useCreditsDto);
|
||||
}
|
||||
|
||||
@Get('transactions')
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { CreditsController } from './credits.controller';
|
||||
import { GuildCreditController } from './guild.controller';
|
||||
import { CreditsService } from './credits.service';
|
||||
import { GuildPoolService } from './guild-pool.service';
|
||||
import { StripeModule } from '../stripe/stripe.module';
|
||||
|
||||
@Module({
|
||||
imports: [forwardRef(() => StripeModule)],
|
||||
controllers: [CreditsController],
|
||||
providers: [CreditsService],
|
||||
exports: [CreditsService],
|
||||
controllers: [CreditsController, GuildCreditController],
|
||||
providers: [CreditsService, GuildPoolService],
|
||||
exports: [CreditsService, GuildPoolService],
|
||||
})
|
||||
export class CreditsModule {}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { getDb } from '../db/connection';
|
|||
import { balances, transactions, purchases, packages, usageStats, users } from '../db/schema';
|
||||
import { UseCreditsDto } from './dto/use-credits.dto';
|
||||
import { StripeService } from '../stripe/stripe.service';
|
||||
import { GuildPoolService } from './guild-pool.service';
|
||||
|
||||
@Injectable()
|
||||
export class CreditsService {
|
||||
|
|
@ -21,7 +22,9 @@ export class CreditsService {
|
|||
constructor(
|
||||
private configService: ConfigService,
|
||||
@Inject(forwardRef(() => StripeService))
|
||||
private stripeService: StripeService
|
||||
private stripeService: StripeService,
|
||||
@Inject(forwardRef(() => GuildPoolService))
|
||||
private guildPoolService: GuildPoolService
|
||||
) {}
|
||||
|
||||
private getDb() {
|
||||
|
|
@ -172,6 +175,17 @@ export class CreditsService {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Use credits with source routing. If creditSource is 'guild', routes to guild pool.
|
||||
* Otherwise uses personal balance. Backward compatible — no creditSource = personal.
|
||||
*/
|
||||
async useCreditsWithSource(userId: string, dto: UseCreditsDto) {
|
||||
if (dto.creditSource?.type === 'guild' && dto.creditSource.guildId) {
|
||||
return this.guildPoolService.useGuildCredits(dto.creditSource.guildId, userId, dto);
|
||||
}
|
||||
return this.useCredits(userId, dto);
|
||||
}
|
||||
|
||||
async getTransactionHistory(userId: string, limit = 50, offset = 0) {
|
||||
const db = this.getDb();
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
import { IsInt, IsPositive, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class FundGuildPoolDto {
|
||||
@IsInt()
|
||||
@IsPositive()
|
||||
amount: number;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { IsInt, IsOptional, Min } from 'class-validator';
|
||||
|
||||
export class SetSpendingLimitDto {
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
dailyLimit?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
monthlyLimit?: number | null;
|
||||
}
|
||||
|
|
@ -1,4 +1,23 @@
|
|||
import { IsString, IsInt, IsPositive, IsOptional, IsObject } from 'class-validator';
|
||||
import {
|
||||
IsString,
|
||||
IsInt,
|
||||
IsPositive,
|
||||
IsOptional,
|
||||
IsObject,
|
||||
IsIn,
|
||||
ValidateNested,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class CreditSourceDto {
|
||||
@IsIn(['personal', 'guild'])
|
||||
type: 'personal' | 'guild';
|
||||
|
||||
@ValidateIf((o) => o.type === 'guild')
|
||||
@IsString()
|
||||
guildId?: string;
|
||||
}
|
||||
|
||||
export class UseCreditsDto {
|
||||
@IsInt()
|
||||
|
|
@ -18,4 +37,9 @@ export class UseCreditsDto {
|
|||
@IsObject()
|
||||
@IsOptional()
|
||||
metadata?: Record<string, any>;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => CreditSourceDto)
|
||||
creditSource?: CreditSourceDto;
|
||||
}
|
||||
|
|
|
|||
581
services/mana-core-auth/src/credits/guild-pool.service.ts
Normal file
581
services/mana-core-auth/src/credits/guild-pool.service.ts
Normal file
|
|
@ -0,0 +1,581 @@
|
|||
import {
|
||||
Injectable,
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { eq, and, desc, gte, sql } from 'drizzle-orm';
|
||||
import { getDb } from '../db/connection';
|
||||
import {
|
||||
balances,
|
||||
transactions,
|
||||
guildPools,
|
||||
guildTransactions,
|
||||
guildSpendingLimits,
|
||||
members,
|
||||
usageStats,
|
||||
} from '../db/schema';
|
||||
import { UseCreditsDto } from './dto/use-credits.dto';
|
||||
|
||||
@Injectable()
|
||||
export class GuildPoolService {
|
||||
private readonly logger = new Logger(GuildPoolService.name);
|
||||
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
private getDb() {
|
||||
const databaseUrl = this.configService.get<string>('database.url');
|
||||
return getDb(databaseUrl!);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify user is a member of the guild. Returns the member record.
|
||||
*/
|
||||
private async verifyMembership(db: ReturnType<typeof getDb>, guildId: string, userId: string) {
|
||||
const [member] = await db
|
||||
.select()
|
||||
.from(members)
|
||||
.where(and(eq(members.organizationId, guildId), eq(members.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!member) {
|
||||
throw new ForbiddenException('User is not a member of this guild');
|
||||
}
|
||||
|
||||
return member;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify user is owner or admin of the guild.
|
||||
*/
|
||||
private async verifyOwnerOrAdmin(db: ReturnType<typeof getDb>, guildId: string, userId: string) {
|
||||
const member = await this.verifyMembership(db, guildId, userId);
|
||||
|
||||
if (member.role !== 'owner' && member.role !== 'admin') {
|
||||
throw new ForbiddenException('Only guild owners and admins can perform this action');
|
||||
}
|
||||
|
||||
return member;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a guild pool with balance 0. Called when a guild is created.
|
||||
*/
|
||||
async initializeGuildPool(organizationId: string) {
|
||||
const db = this.getDb();
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(guildPools)
|
||||
.where(eq(guildPools.organizationId, organizationId))
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const [pool] = await db.insert(guildPools).values({ organizationId }).returning();
|
||||
|
||||
this.logger.log('Guild pool initialized', { organizationId });
|
||||
return pool;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get guild pool balance. Verifies the requesting user is a guild member.
|
||||
*/
|
||||
async getGuildPoolBalance(guildId: string, userId: string) {
|
||||
const db = this.getDb();
|
||||
await this.verifyMembership(db, guildId, userId);
|
||||
|
||||
const [pool] = await db
|
||||
.select()
|
||||
.from(guildPools)
|
||||
.where(eq(guildPools.organizationId, guildId))
|
||||
.limit(1);
|
||||
|
||||
if (!pool) {
|
||||
throw new NotFoundException('Guild pool not found');
|
||||
}
|
||||
|
||||
return {
|
||||
balance: pool.balance,
|
||||
totalFunded: pool.totalFunded,
|
||||
totalSpent: pool.totalSpent,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fund the guild pool from a user's personal balance.
|
||||
* Only owners and admins can fund.
|
||||
*/
|
||||
async fundGuildPool(guildId: string, funderId: string, amount: number, idempotencyKey?: string) {
|
||||
if (amount <= 0) {
|
||||
throw new BadRequestException('Amount must be positive');
|
||||
}
|
||||
|
||||
const db = this.getDb();
|
||||
await this.verifyOwnerOrAdmin(db, guildId, funderId);
|
||||
|
||||
// Check idempotency
|
||||
if (idempotencyKey) {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(guildTransactions)
|
||||
.where(eq(guildTransactions.idempotencyKey, idempotencyKey))
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
return { success: true, message: 'Transaction already processed' };
|
||||
}
|
||||
}
|
||||
|
||||
return await db.transaction(async (tx) => {
|
||||
// Lock and check personal balance
|
||||
const [personalBalance] = await tx
|
||||
.select()
|
||||
.from(balances)
|
||||
.where(eq(balances.userId, funderId))
|
||||
.for('update')
|
||||
.limit(1);
|
||||
|
||||
if (!personalBalance) {
|
||||
throw new NotFoundException('Personal balance not found');
|
||||
}
|
||||
|
||||
if (personalBalance.balance < amount) {
|
||||
throw new BadRequestException('Insufficient personal credits');
|
||||
}
|
||||
|
||||
// Lock and update guild pool
|
||||
const [pool] = await tx
|
||||
.select()
|
||||
.from(guildPools)
|
||||
.where(eq(guildPools.organizationId, guildId))
|
||||
.for('update')
|
||||
.limit(1);
|
||||
|
||||
if (!pool) {
|
||||
throw new NotFoundException('Guild pool not found');
|
||||
}
|
||||
|
||||
const newPersonalBalance = personalBalance.balance - amount;
|
||||
const newPoolBalance = pool.balance + amount;
|
||||
|
||||
// Debit personal balance
|
||||
const personalUpdate = await tx
|
||||
.update(balances)
|
||||
.set({
|
||||
balance: newPersonalBalance,
|
||||
totalSpent: personalBalance.totalSpent + amount,
|
||||
version: personalBalance.version + 1,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(balances.userId, funderId), eq(balances.version, personalBalance.version)))
|
||||
.returning();
|
||||
|
||||
if (personalUpdate.length === 0) {
|
||||
throw new ConflictException('Personal balance was modified concurrently. Please retry.');
|
||||
}
|
||||
|
||||
// Credit guild pool
|
||||
const poolUpdate = await tx
|
||||
.update(guildPools)
|
||||
.set({
|
||||
balance: newPoolBalance,
|
||||
totalFunded: pool.totalFunded + amount,
|
||||
version: pool.version + 1,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(guildPools.organizationId, guildId), eq(guildPools.version, pool.version)))
|
||||
.returning();
|
||||
|
||||
if (poolUpdate.length === 0) {
|
||||
throw new ConflictException('Guild pool was modified concurrently. Please retry.');
|
||||
}
|
||||
|
||||
// Record personal transaction (debit)
|
||||
await tx.insert(transactions).values({
|
||||
userId: funderId,
|
||||
type: 'guild_funding',
|
||||
status: 'completed',
|
||||
amount: -amount,
|
||||
balanceBefore: personalBalance.balance,
|
||||
balanceAfter: newPersonalBalance,
|
||||
appId: 'guild',
|
||||
description: `Funded guild pool`,
|
||||
guildId,
|
||||
idempotencyKey: idempotencyKey ? `personal:${idempotencyKey}` : undefined,
|
||||
completedAt: new Date(),
|
||||
});
|
||||
|
||||
// Record guild transaction (credit)
|
||||
await tx.insert(guildTransactions).values({
|
||||
organizationId: guildId,
|
||||
userId: funderId,
|
||||
type: 'funding',
|
||||
amount,
|
||||
balanceBefore: pool.balance,
|
||||
balanceAfter: newPoolBalance,
|
||||
description: `Pool funded by member`,
|
||||
idempotencyKey,
|
||||
completedAt: new Date(),
|
||||
});
|
||||
|
||||
this.logger.log('Guild pool funded', { guildId, funderId, amount, newPoolBalance });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
personalBalance: { balance: newPersonalBalance },
|
||||
poolBalance: { balance: newPoolBalance, totalFunded: pool.totalFunded + amount },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Use credits from the guild pool. Any member can use, subject to spending limits.
|
||||
*/
|
||||
async useGuildCredits(guildId: string, userId: string, dto: UseCreditsDto) {
|
||||
const db = this.getDb();
|
||||
await this.verifyMembership(db, guildId, userId);
|
||||
|
||||
// Check idempotency
|
||||
if (dto.idempotencyKey) {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(guildTransactions)
|
||||
.where(eq(guildTransactions.idempotencyKey, dto.idempotencyKey))
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
return { success: true, message: 'Transaction already processed' };
|
||||
}
|
||||
}
|
||||
|
||||
// Check spending limits before entering transaction
|
||||
await this.checkSpendingLimits(db, guildId, userId, dto.amount);
|
||||
|
||||
return await db.transaction(async (tx) => {
|
||||
// Lock guild pool
|
||||
const [pool] = await tx
|
||||
.select()
|
||||
.from(guildPools)
|
||||
.where(eq(guildPools.organizationId, guildId))
|
||||
.for('update')
|
||||
.limit(1);
|
||||
|
||||
if (!pool) {
|
||||
throw new NotFoundException('Guild pool not found');
|
||||
}
|
||||
|
||||
if (pool.balance < dto.amount) {
|
||||
throw new BadRequestException('Insufficient guild pool credits');
|
||||
}
|
||||
|
||||
const newBalance = pool.balance - dto.amount;
|
||||
|
||||
// Update pool
|
||||
const poolUpdate = await tx
|
||||
.update(guildPools)
|
||||
.set({
|
||||
balance: newBalance,
|
||||
totalSpent: pool.totalSpent + dto.amount,
|
||||
version: pool.version + 1,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(guildPools.organizationId, guildId), eq(guildPools.version, pool.version)))
|
||||
.returning();
|
||||
|
||||
if (poolUpdate.length === 0) {
|
||||
throw new ConflictException('Guild pool was modified concurrently. Please retry.');
|
||||
}
|
||||
|
||||
// Record guild transaction
|
||||
const [transaction] = await tx
|
||||
.insert(guildTransactions)
|
||||
.values({
|
||||
organizationId: guildId,
|
||||
userId,
|
||||
type: 'usage',
|
||||
amount: -dto.amount,
|
||||
balanceBefore: pool.balance,
|
||||
balanceAfter: newBalance,
|
||||
appId: dto.appId,
|
||||
description: dto.description,
|
||||
metadata: dto.metadata,
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
completedAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Track usage stats
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
await tx.insert(usageStats).values({
|
||||
userId,
|
||||
appId: dto.appId,
|
||||
creditsUsed: dto.amount,
|
||||
date: today,
|
||||
metadata: { ...dto.metadata, guildId },
|
||||
});
|
||||
|
||||
this.logger.log('Guild credits used', {
|
||||
guildId,
|
||||
userId,
|
||||
amount: dto.amount,
|
||||
appId: dto.appId,
|
||||
newBalance,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
transaction,
|
||||
newBalance: { balance: newBalance },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user's spending is within their limits for this guild.
|
||||
*/
|
||||
private async checkSpendingLimits(
|
||||
db: ReturnType<typeof getDb>,
|
||||
guildId: string,
|
||||
userId: string,
|
||||
amount: number
|
||||
) {
|
||||
const [limits] = await db
|
||||
.select()
|
||||
.from(guildSpendingLimits)
|
||||
.where(
|
||||
and(eq(guildSpendingLimits.organizationId, guildId), eq(guildSpendingLimits.userId, userId))
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
// No limits set = unlimited
|
||||
if (!limits) return;
|
||||
|
||||
const now = new Date();
|
||||
|
||||
if (limits.dailyLimit !== null) {
|
||||
const startOfDay = new Date(now);
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
|
||||
const [dailySpending] = await db
|
||||
.select({
|
||||
total: sql<number>`COALESCE(SUM(ABS(${guildTransactions.amount})), 0)`,
|
||||
})
|
||||
.from(guildTransactions)
|
||||
.where(
|
||||
and(
|
||||
eq(guildTransactions.organizationId, guildId),
|
||||
eq(guildTransactions.userId, userId),
|
||||
eq(guildTransactions.type, 'usage'),
|
||||
gte(guildTransactions.createdAt, startOfDay)
|
||||
)
|
||||
);
|
||||
|
||||
const spent = Number(dailySpending.total);
|
||||
if (spent + amount > limits.dailyLimit) {
|
||||
throw new BadRequestException(
|
||||
`Daily spending limit exceeded. Limit: ${limits.dailyLimit}, spent today: ${spent}, requested: ${amount}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (limits.monthlyLimit !== null) {
|
||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
|
||||
const [monthlySpending] = await db
|
||||
.select({
|
||||
total: sql<number>`COALESCE(SUM(ABS(${guildTransactions.amount})), 0)`,
|
||||
})
|
||||
.from(guildTransactions)
|
||||
.where(
|
||||
and(
|
||||
eq(guildTransactions.organizationId, guildId),
|
||||
eq(guildTransactions.userId, userId),
|
||||
eq(guildTransactions.type, 'usage'),
|
||||
gte(guildTransactions.createdAt, startOfMonth)
|
||||
)
|
||||
);
|
||||
|
||||
const spent = Number(monthlySpending.total);
|
||||
if (spent + amount > limits.monthlyLimit) {
|
||||
throw new BadRequestException(
|
||||
`Monthly spending limit exceeded. Limit: ${limits.monthlyLimit}, spent this month: ${spent}, requested: ${amount}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get guild transaction history. Owners/admins see all; members see only their own.
|
||||
*/
|
||||
async getGuildTransactions(guildId: string, userId: string, limit = 50, offset = 0) {
|
||||
const db = this.getDb();
|
||||
const member = await this.verifyMembership(db, guildId, userId);
|
||||
|
||||
const isAdmin = member.role === 'owner' || member.role === 'admin';
|
||||
|
||||
const conditions = [eq(guildTransactions.organizationId, guildId)];
|
||||
|
||||
// Members can only see their own transactions
|
||||
if (!isAdmin) {
|
||||
conditions.push(eq(guildTransactions.userId, userId));
|
||||
}
|
||||
|
||||
return await db
|
||||
.select()
|
||||
.from(guildTransactions)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(guildTransactions.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set spending limits for a guild member. Only owner/admin can set limits.
|
||||
*/
|
||||
async setSpendingLimit(
|
||||
guildId: string,
|
||||
setterId: string,
|
||||
targetUserId: string,
|
||||
dailyLimit?: number | null,
|
||||
monthlyLimit?: number | null
|
||||
) {
|
||||
const db = this.getDb();
|
||||
await this.verifyOwnerOrAdmin(db, guildId, setterId);
|
||||
await this.verifyMembership(db, guildId, targetUserId);
|
||||
|
||||
if (dailyLimit !== undefined && dailyLimit !== null && dailyLimit < 0) {
|
||||
throw new BadRequestException('Daily limit must be non-negative');
|
||||
}
|
||||
if (monthlyLimit !== undefined && monthlyLimit !== null && monthlyLimit < 0) {
|
||||
throw new BadRequestException('Monthly limit must be non-negative');
|
||||
}
|
||||
|
||||
// Upsert spending limits
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(guildSpendingLimits)
|
||||
.where(
|
||||
and(
|
||||
eq(guildSpendingLimits.organizationId, guildId),
|
||||
eq(guildSpendingLimits.userId, targetUserId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
const [updated] = await db
|
||||
.update(guildSpendingLimits)
|
||||
.set({
|
||||
dailyLimit: dailyLimit === undefined ? existing.dailyLimit : dailyLimit,
|
||||
monthlyLimit: monthlyLimit === undefined ? existing.monthlyLimit : monthlyLimit,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(guildSpendingLimits.id, existing.id))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
const [created] = await db
|
||||
.insert(guildSpendingLimits)
|
||||
.values({
|
||||
organizationId: guildId,
|
||||
userId: targetUserId,
|
||||
dailyLimit: dailyLimit ?? null,
|
||||
monthlyLimit: monthlyLimit ?? null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get spending limits for a guild member.
|
||||
*/
|
||||
async getSpendingLimits(guildId: string, userId: string) {
|
||||
const db = this.getDb();
|
||||
await this.verifyMembership(db, guildId, userId);
|
||||
|
||||
const [limits] = await db
|
||||
.select()
|
||||
.from(guildSpendingLimits)
|
||||
.where(
|
||||
and(eq(guildSpendingLimits.organizationId, guildId), eq(guildSpendingLimits.userId, userId))
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return limits || { dailyLimit: null, monthlyLimit: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a member's spending summary (today + this month) vs their limits.
|
||||
*/
|
||||
async getMemberSpendingSummary(guildId: string, userId: string) {
|
||||
const db = this.getDb();
|
||||
await this.verifyMembership(db, guildId, userId);
|
||||
|
||||
const now = new Date();
|
||||
const startOfDay = new Date(now);
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
|
||||
const [dailySpending] = await db
|
||||
.select({
|
||||
total: sql<number>`COALESCE(SUM(ABS(${guildTransactions.amount})), 0)`,
|
||||
})
|
||||
.from(guildTransactions)
|
||||
.where(
|
||||
and(
|
||||
eq(guildTransactions.organizationId, guildId),
|
||||
eq(guildTransactions.userId, userId),
|
||||
eq(guildTransactions.type, 'usage'),
|
||||
gte(guildTransactions.createdAt, startOfDay)
|
||||
)
|
||||
);
|
||||
|
||||
const [monthlySpending] = await db
|
||||
.select({
|
||||
total: sql<number>`COALESCE(SUM(ABS(${guildTransactions.amount})), 0)`,
|
||||
})
|
||||
.from(guildTransactions)
|
||||
.where(
|
||||
and(
|
||||
eq(guildTransactions.organizationId, guildId),
|
||||
eq(guildTransactions.userId, userId),
|
||||
eq(guildTransactions.type, 'usage'),
|
||||
gte(guildTransactions.createdAt, startOfMonth)
|
||||
)
|
||||
);
|
||||
|
||||
const [limits] = await db
|
||||
.select()
|
||||
.from(guildSpendingLimits)
|
||||
.where(
|
||||
and(eq(guildSpendingLimits.organizationId, guildId), eq(guildSpendingLimits.userId, userId))
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return {
|
||||
spentToday: Number(dailySpending.total),
|
||||
spentThisMonth: Number(monthlySpending.total),
|
||||
dailyLimit: limits?.dailyLimit ?? null,
|
||||
monthlyLimit: limits?.monthlyLimit ?? null,
|
||||
dailyRemaining:
|
||||
limits?.dailyLimit !== null && limits?.dailyLimit !== undefined
|
||||
? Math.max(0, limits.dailyLimit - Number(dailySpending.total))
|
||||
: null,
|
||||
monthlyRemaining:
|
||||
limits?.monthlyLimit !== null && limits?.monthlyLimit !== undefined
|
||||
? Math.max(0, limits.monthlyLimit - Number(monthlySpending.total))
|
||||
: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
122
services/mana-core-auth/src/credits/guild.controller.ts
Normal file
122
services/mana-core-auth/src/credits/guild.controller.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
ParseIntPipe,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { GuildPoolService } from './guild-pool.service';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||
import type { CurrentUserData } from '../common/decorators/current-user.decorator';
|
||||
import { UseCreditsDto } from './dto/use-credits.dto';
|
||||
import { FundGuildPoolDto } from './dto/fund-guild-pool.dto';
|
||||
import { SetSpendingLimitDto } from './dto/set-spending-limit.dto';
|
||||
|
||||
@ApiTags('credits/guild')
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@Controller('credits/guild')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class GuildCreditController {
|
||||
constructor(private readonly guildPoolService: GuildPoolService) {}
|
||||
|
||||
@Get(':guildId/balance')
|
||||
@ApiOperation({ summary: 'Get guild pool balance' })
|
||||
@ApiResponse({ status: 200, description: 'Returns guild pool balance' })
|
||||
@ApiResponse({ status: 403, description: 'Not a member of this guild' })
|
||||
async getBalance(@Param('guildId') guildId: string, @CurrentUser() user: CurrentUserData) {
|
||||
return this.guildPoolService.getGuildPoolBalance(guildId, user.userId);
|
||||
}
|
||||
|
||||
@Post(':guildId/fund')
|
||||
@ApiOperation({ summary: 'Fund guild pool from personal balance' })
|
||||
@ApiResponse({ status: 200, description: 'Pool funded successfully' })
|
||||
@ApiResponse({ status: 400, description: 'Insufficient personal credits' })
|
||||
@ApiResponse({ status: 403, description: 'Only owners and admins can fund' })
|
||||
async fundPool(
|
||||
@Param('guildId') guildId: string,
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Body() dto: FundGuildPoolDto
|
||||
) {
|
||||
return this.guildPoolService.fundGuildPool(
|
||||
guildId,
|
||||
user.userId,
|
||||
dto.amount,
|
||||
dto.idempotencyKey
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':guildId/use')
|
||||
@ApiOperation({ summary: 'Use credits from guild pool' })
|
||||
@ApiResponse({ status: 200, description: 'Credits used successfully' })
|
||||
@ApiResponse({ status: 400, description: 'Insufficient credits or spending limit exceeded' })
|
||||
@ApiResponse({ status: 403, description: 'Not a member of this guild' })
|
||||
async useCredits(
|
||||
@Param('guildId') guildId: string,
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Body() dto: UseCreditsDto
|
||||
) {
|
||||
return this.guildPoolService.useGuildCredits(guildId, user.userId, dto);
|
||||
}
|
||||
|
||||
@Get(':guildId/transactions')
|
||||
@ApiOperation({ summary: 'Get guild transaction history' })
|
||||
@ApiResponse({ status: 200, description: 'Returns guild transactions' })
|
||||
async getTransactions(
|
||||
@Param('guildId') guildId: string,
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Query('limit', new ParseIntPipe({ optional: true })) limit?: number,
|
||||
@Query('offset', new ParseIntPipe({ optional: true })) offset?: number
|
||||
) {
|
||||
return this.guildPoolService.getGuildTransactions(guildId, user.userId, limit, offset);
|
||||
}
|
||||
|
||||
@Get(':guildId/members/:userId/spending')
|
||||
@ApiOperation({ summary: 'Get member spending summary' })
|
||||
@ApiResponse({ status: 200, description: 'Returns spending summary with limits' })
|
||||
async getMemberSpending(
|
||||
@Param('guildId') guildId: string,
|
||||
@Param('userId') targetUserId: string,
|
||||
@CurrentUser() user: CurrentUserData
|
||||
) {
|
||||
// Members can view their own spending, owners/admins can view any member
|
||||
const effectiveUserId = targetUserId === 'me' ? user.userId : targetUserId;
|
||||
return this.guildPoolService.getMemberSpendingSummary(guildId, effectiveUserId);
|
||||
}
|
||||
|
||||
@Get(':guildId/members/:userId/limits')
|
||||
@ApiOperation({ summary: 'Get member spending limits' })
|
||||
@ApiResponse({ status: 200, description: 'Returns spending limits' })
|
||||
async getSpendingLimits(
|
||||
@Param('guildId') guildId: string,
|
||||
@Param('userId') targetUserId: string,
|
||||
@CurrentUser() user: CurrentUserData
|
||||
) {
|
||||
const effectiveUserId = targetUserId === 'me' ? user.userId : targetUserId;
|
||||
return this.guildPoolService.getSpendingLimits(guildId, effectiveUserId);
|
||||
}
|
||||
|
||||
@Put(':guildId/members/:userId/limits')
|
||||
@ApiOperation({ summary: 'Set member spending limits' })
|
||||
@ApiResponse({ status: 200, description: 'Spending limits updated' })
|
||||
@ApiResponse({ status: 403, description: 'Only owners and admins can set limits' })
|
||||
async setSpendingLimits(
|
||||
@Param('guildId') guildId: string,
|
||||
@Param('userId') targetUserId: string,
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Body() dto: SetSpendingLimitDto
|
||||
) {
|
||||
return this.guildPoolService.setSpendingLimit(
|
||||
guildId,
|
||||
user.userId,
|
||||
targetUserId,
|
||||
dto.dailyLimit,
|
||||
dto.monthlyLimit
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ export const transactionTypeEnum = pgEnum('transaction_type', [
|
|||
'usage',
|
||||
'refund',
|
||||
'gift',
|
||||
'guild_funding',
|
||||
]);
|
||||
|
||||
// Transaction status enum
|
||||
|
|
@ -72,6 +73,7 @@ export const transactions = creditsSchema.table(
|
|||
description: text('description').notNull(),
|
||||
metadata: jsonb('metadata'),
|
||||
idempotencyKey: text('idempotency_key').unique(),
|
||||
guildId: text('guild_id'), // Set when transaction is guild-related (e.g. funding a guild pool)
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
completedAt: timestamp('completed_at', { withTimezone: true }),
|
||||
},
|
||||
|
|
@ -80,6 +82,7 @@ export const transactions = creditsSchema.table(
|
|||
appIdIdx: index('transactions_app_id_idx').on(table.appId),
|
||||
createdAtIdx: index('transactions_created_at_idx').on(table.createdAt),
|
||||
idempotencyKeyIdx: index('transactions_idempotency_key_idx').on(table.idempotencyKey),
|
||||
guildIdIdx: index('transactions_guild_id_idx').on(table.guildId),
|
||||
})
|
||||
);
|
||||
|
||||
|
|
@ -143,4 +146,4 @@ export const usageStats = creditsSchema.table(
|
|||
})
|
||||
);
|
||||
|
||||
// B2B organization credit tables removed - simplified to B2C only
|
||||
// Guild pool tables are in guilds.schema.ts
|
||||
|
|
|
|||
87
services/mana-core-auth/src/db/schema/guilds.schema.ts
Normal file
87
services/mana-core-auth/src/db/schema/guilds.schema.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { uuid, integer, text, timestamp, jsonb, index, unique } from 'drizzle-orm/pg-core';
|
||||
import { creditsSchema } from './credits.schema';
|
||||
import { organizations } from './organizations.schema';
|
||||
import { users } from './auth.schema';
|
||||
|
||||
/**
|
||||
* Guild Pool Tables
|
||||
*
|
||||
* Shared Mana pools for guilds (Gilden). Members spend directly from the pool
|
||||
* instead of receiving individual allocations. The Gildenmeister (owner) manages
|
||||
* funding and optional spending limits per member.
|
||||
*/
|
||||
|
||||
// Guild Mana pool (one per guild/organization)
|
||||
export const guildPools = creditsSchema.table('guild_pools', {
|
||||
organizationId: text('organization_id')
|
||||
.primaryKey()
|
||||
.references(() => organizations.id, { onDelete: 'cascade' }),
|
||||
balance: integer('balance').default(0).notNull(),
|
||||
totalFunded: integer('total_funded').default(0).notNull(),
|
||||
totalSpent: integer('total_spent').default(0).notNull(),
|
||||
version: integer('version').default(0).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// Optional per-member spending limits
|
||||
export const guildSpendingLimits = creditsSchema.table(
|
||||
'guild_spending_limits',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
organizationId: text('organization_id')
|
||||
.references(() => organizations.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
userId: text('user_id')
|
||||
.references(() => users.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
dailyLimit: integer('daily_limit'), // null = unlimited
|
||||
monthlyLimit: integer('monthly_limit'), // null = unlimited
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
orgUserUnique: unique('guild_spending_limits_org_user_unique').on(
|
||||
table.organizationId,
|
||||
table.userId
|
||||
),
|
||||
organizationIdIdx: index('guild_spending_limits_org_id_idx').on(table.organizationId),
|
||||
userIdIdx: index('guild_spending_limits_user_id_idx').on(table.userId),
|
||||
})
|
||||
);
|
||||
|
||||
// Immutable transaction ledger for guild pool
|
||||
export const guildTransactions = creditsSchema.table(
|
||||
'guild_transactions',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
organizationId: text('organization_id')
|
||||
.references(() => organizations.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
userId: text('user_id')
|
||||
.references(() => users.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
type: text('type').notNull(), // 'funding', 'usage', 'refund'
|
||||
amount: integer('amount').notNull(), // positive for funding, negative for usage
|
||||
balanceBefore: integer('balance_before').notNull(),
|
||||
balanceAfter: integer('balance_after').notNull(),
|
||||
appId: text('app_id'),
|
||||
description: text('description').notNull(),
|
||||
metadata: jsonb('metadata'),
|
||||
idempotencyKey: text('idempotency_key').unique(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
completedAt: timestamp('completed_at', { withTimezone: true }),
|
||||
},
|
||||
(table) => ({
|
||||
organizationIdIdx: index('guild_transactions_org_id_idx').on(table.organizationId),
|
||||
userIdIdx: index('guild_transactions_user_id_idx').on(table.userId),
|
||||
createdAtIdx: index('guild_transactions_created_at_idx').on(table.createdAt),
|
||||
idempotencyKeyIdx: index('guild_transactions_idempotency_key_idx').on(table.idempotencyKey),
|
||||
// For spending limit queries: user's spending within a guild in a time window
|
||||
orgUserCreatedIdx: index('guild_transactions_org_user_created_idx').on(
|
||||
table.organizationId,
|
||||
table.userId,
|
||||
table.createdAt
|
||||
),
|
||||
})
|
||||
);
|
||||
|
|
@ -3,6 +3,7 @@ export * from './auth.schema';
|
|||
export * from './credits.schema';
|
||||
export * from './feedback.schema';
|
||||
export * from './gifts.schema';
|
||||
export * from './guilds.schema';
|
||||
export * from './login-attempts.schema';
|
||||
export * from './organizations.schema';
|
||||
export * from './subscriptions.schema';
|
||||
|
|
|
|||
149
services/mana-core-auth/src/guilds/guilds.controller.ts
Normal file
149
services/mana-core-auth/src/guilds/guilds.controller.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Headers,
|
||||
UseGuards,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { GuildsService, CreateGuildDto } from './guilds.service';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||
import type { CurrentUserData } from '../common/decorators/current-user.decorator';
|
||||
import { UpdateOrganizationDto } from '../auth/dto/update-organization.dto';
|
||||
|
||||
class InviteMemberDto {
|
||||
email: string;
|
||||
role: 'admin' | 'member';
|
||||
}
|
||||
|
||||
class AcceptInvitationBodyDto {
|
||||
invitationId: string;
|
||||
}
|
||||
|
||||
@ApiTags('gilden')
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@Controller('gilden')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class GuildsController {
|
||||
constructor(private readonly guildsService: GuildsService) {}
|
||||
|
||||
private extractToken(authorization: string): string {
|
||||
return authorization?.replace('Bearer ', '') || '';
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a new guild' })
|
||||
@ApiResponse({ status: 201, description: 'Guild created with pool initialized' })
|
||||
async createGuild(@Headers('authorization') authorization: string, @Body() dto: CreateGuildDto) {
|
||||
const token = this.extractToken(authorization);
|
||||
return this.guildsService.createGuild(token, dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: "List user's guilds" })
|
||||
@ApiResponse({ status: 200, description: 'Returns list of guilds with pool balances' })
|
||||
async listGuilds(
|
||||
@Headers('authorization') authorization: string,
|
||||
@CurrentUser() user: CurrentUserData
|
||||
) {
|
||||
const token = this.extractToken(authorization);
|
||||
return this.guildsService.listGuilds(token, user.userId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get guild details with pool balance and members' })
|
||||
@ApiResponse({ status: 200, description: 'Returns guild details' })
|
||||
@ApiResponse({ status: 404, description: 'Guild not found' })
|
||||
async getGuild(
|
||||
@Param('id') id: string,
|
||||
@Headers('authorization') authorization: string,
|
||||
@CurrentUser() user: CurrentUserData
|
||||
) {
|
||||
const token = this.extractToken(authorization);
|
||||
return this.guildsService.getGuild(id, token, user.userId);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Update guild details' })
|
||||
@ApiResponse({ status: 200, description: 'Guild updated' })
|
||||
@ApiResponse({ status: 403, description: 'Only owners and admins can update' })
|
||||
async updateGuild(
|
||||
@Param('id') id: string,
|
||||
@Headers('authorization') authorization: string,
|
||||
@Body() dto: UpdateOrganizationDto
|
||||
) {
|
||||
const token = this.extractToken(authorization);
|
||||
return this.guildsService.updateGuild(id, dto, token);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Delete guild (cascades to pool)' })
|
||||
@ApiResponse({ status: 204, description: 'Guild deleted' })
|
||||
@ApiResponse({ status: 403, description: 'Only owners can delete' })
|
||||
async deleteGuild(@Param('id') id: string, @Headers('authorization') authorization: string) {
|
||||
const token = this.extractToken(authorization);
|
||||
await this.guildsService.deleteGuild(id, token);
|
||||
}
|
||||
|
||||
@Post(':id/invite')
|
||||
@ApiOperation({ summary: 'Invite a member to the guild' })
|
||||
@ApiResponse({ status: 200, description: 'Invitation sent' })
|
||||
@ApiResponse({ status: 403, description: 'Only owners and admins can invite' })
|
||||
async inviteMember(
|
||||
@Param('id') guildId: string,
|
||||
@Headers('authorization') authorization: string,
|
||||
@Body() dto: InviteMemberDto
|
||||
) {
|
||||
const token = this.extractToken(authorization);
|
||||
return this.guildsService.inviteMember(guildId, dto.email, dto.role, token);
|
||||
}
|
||||
|
||||
@Post('accept-invitation')
|
||||
@ApiOperation({ summary: 'Accept a guild invitation' })
|
||||
@ApiResponse({ status: 200, description: 'Invitation accepted' })
|
||||
async acceptInvitation(
|
||||
@Headers('authorization') authorization: string,
|
||||
@Body() dto: AcceptInvitationBodyDto
|
||||
) {
|
||||
const token = this.extractToken(authorization);
|
||||
return this.guildsService.acceptInvitation(dto.invitationId, token);
|
||||
}
|
||||
|
||||
@Delete(':id/members/:memberId')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Remove a member from the guild' })
|
||||
@ApiResponse({ status: 204, description: 'Member removed' })
|
||||
@ApiResponse({ status: 403, description: 'Only owners and admins can remove members' })
|
||||
async removeMember(
|
||||
@Param('id') guildId: string,
|
||||
@Param('memberId') memberId: string,
|
||||
@Headers('authorization') authorization: string
|
||||
) {
|
||||
const token = this.extractToken(authorization);
|
||||
await this.guildsService.removeMember(guildId, memberId, token);
|
||||
}
|
||||
|
||||
@Put(':id/members/:memberId/role')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Update member role' })
|
||||
@ApiResponse({ status: 200, description: 'Role updated' })
|
||||
@ApiResponse({ status: 403, description: 'Only owners and admins can change roles' })
|
||||
async updateMemberRole(
|
||||
@Param('id') guildId: string,
|
||||
@Param('memberId') memberId: string,
|
||||
@Headers('authorization') authorization: string,
|
||||
@Body() dto: { role: string }
|
||||
) {
|
||||
const token = this.extractToken(authorization);
|
||||
return this.guildsService.updateMemberRole(guildId, memberId, dto.role, token);
|
||||
}
|
||||
}
|
||||
13
services/mana-core-auth/src/guilds/guilds.module.ts
Normal file
13
services/mana-core-auth/src/guilds/guilds.module.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { GuildsController } from './guilds.controller';
|
||||
import { GuildsService } from './guilds.service';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { CreditsModule } from '../credits/credits.module';
|
||||
|
||||
@Module({
|
||||
imports: [forwardRef(() => AuthModule), forwardRef(() => CreditsModule)],
|
||||
controllers: [GuildsController],
|
||||
providers: [GuildsService],
|
||||
exports: [GuildsService],
|
||||
})
|
||||
export class GuildsModule {}
|
||||
195
services/mana-core-auth/src/guilds/guilds.service.ts
Normal file
195
services/mana-core-auth/src/guilds/guilds.service.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import { Injectable, ForbiddenException, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { getDb } from '../db/connection';
|
||||
import { members, organizations } from '../db/schema';
|
||||
import { BetterAuthService } from '../auth/services/better-auth.service';
|
||||
import { GuildPoolService } from '../credits/guild-pool.service';
|
||||
import { InviteEmployeeDto } from '../auth/dto/invite-employee.dto';
|
||||
import { AcceptInvitationDto } from '../auth/dto/accept-invitation.dto';
|
||||
import { UpdateOrganizationDto } from '../auth/dto/update-organization.dto';
|
||||
import { UpdateMemberRoleDto } from '../auth/dto/update-member-role.dto';
|
||||
|
||||
export class CreateGuildDto {
|
||||
name: string;
|
||||
slug?: string;
|
||||
logo?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class GuildsService {
|
||||
private readonly logger = new Logger(GuildsService.name);
|
||||
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private betterAuthService: BetterAuthService,
|
||||
private guildPoolService: GuildPoolService
|
||||
) {}
|
||||
|
||||
private getDb() {
|
||||
const databaseUrl = this.configService.get<string>('database.url');
|
||||
return getDb(databaseUrl!);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new guild (organization + pool).
|
||||
* Checks subscription limits for maxOrganizations.
|
||||
*/
|
||||
async createGuild(token: string, dto: CreateGuildDto) {
|
||||
// Create organization via Better Auth
|
||||
const result = await this.betterAuthService.createOrganizationDirect(token, {
|
||||
name: dto.name,
|
||||
slug: dto.slug,
|
||||
logo: dto.logo,
|
||||
});
|
||||
|
||||
// Initialize the guild pool
|
||||
const pool = await this.guildPoolService.initializeGuildPool(result.id);
|
||||
|
||||
this.logger.log('Guild created', { guildId: result.id, name: dto.name });
|
||||
|
||||
return {
|
||||
gilde: {
|
||||
id: result.id,
|
||||
name: result.name,
|
||||
slug: result.slug,
|
||||
logo: result.logo,
|
||||
createdAt: result.createdAt,
|
||||
},
|
||||
pool: {
|
||||
balance: pool.balance,
|
||||
totalFunded: pool.totalFunded,
|
||||
totalSpent: pool.totalSpent,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List user's guilds with pool balances.
|
||||
*/
|
||||
async listGuilds(token: string, userId: string) {
|
||||
const result = await this.betterAuthService.listOrganizations(token);
|
||||
|
||||
const db = this.getDb();
|
||||
const guilds = [];
|
||||
|
||||
for (const org of result.organizations || []) {
|
||||
// Get pool balance for each guild
|
||||
try {
|
||||
const pool = await this.guildPoolService.getGuildPoolBalance(org.id, userId);
|
||||
guilds.push({
|
||||
gilde: {
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
slug: org.slug,
|
||||
logo: org.logo,
|
||||
createdAt: org.createdAt,
|
||||
},
|
||||
pool,
|
||||
role: (org as any).role,
|
||||
});
|
||||
} catch {
|
||||
// Pool might not exist for legacy orgs
|
||||
guilds.push({
|
||||
gilde: {
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
slug: org.slug,
|
||||
logo: org.logo,
|
||||
createdAt: org.createdAt,
|
||||
},
|
||||
pool: null,
|
||||
role: (org as any).role,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { guilds };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get guild details with pool balance and members.
|
||||
*/
|
||||
async getGuild(guildId: string, token: string, userId: string) {
|
||||
const org = await this.betterAuthService.getOrganization(guildId, token);
|
||||
let pool = null;
|
||||
|
||||
try {
|
||||
pool = await this.guildPoolService.getGuildPoolBalance(guildId, userId);
|
||||
} catch {
|
||||
// Pool might not exist
|
||||
}
|
||||
|
||||
return {
|
||||
gilde: {
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
slug: org.slug,
|
||||
logo: org.logo,
|
||||
metadata: org.metadata,
|
||||
createdAt: org.createdAt,
|
||||
},
|
||||
pool,
|
||||
members: org.members,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update guild details.
|
||||
*/
|
||||
async updateGuild(guildId: string, dto: UpdateOrganizationDto, token: string) {
|
||||
return this.betterAuthService.updateOrganization(guildId, dto, token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete guild. Pool is cascade-deleted.
|
||||
*/
|
||||
async deleteGuild(guildId: string, token: string) {
|
||||
return this.betterAuthService.deleteOrganization(guildId, token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite a member to the guild.
|
||||
*/
|
||||
async inviteMember(guildId: string, email: string, role: string, token: string) {
|
||||
return this.betterAuthService.inviteEmployee({
|
||||
organizationId: guildId,
|
||||
employeeEmail: email,
|
||||
role: role as 'admin' | 'member',
|
||||
inviterToken: token,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept a guild invitation.
|
||||
*/
|
||||
async acceptInvitation(invitationId: string, token: string) {
|
||||
return this.betterAuthService.acceptInvitation({
|
||||
invitationId,
|
||||
userToken: token,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a member from the guild.
|
||||
*/
|
||||
async removeMember(guildId: string, memberId: string, token: string) {
|
||||
return this.betterAuthService.removeMember({
|
||||
organizationId: guildId,
|
||||
memberId,
|
||||
removerToken: token,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a member's role.
|
||||
*/
|
||||
async updateMemberRole(guildId: string, memberId: string, role: string, token: string) {
|
||||
return this.betterAuthService.updateMemberRole(
|
||||
guildId,
|
||||
memberId,
|
||||
role as 'admin' | 'member',
|
||||
token
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,961 +0,0 @@
|
|||
/**
|
||||
* B2B Organization Journey E2E Tests
|
||||
*
|
||||
* Complete end-to-end test for B2B workflows:
|
||||
* 1. Register organization with owner
|
||||
* 2. Verify organization credit balance initialized
|
||||
* 3. Invite employees (simulated via direct DB for now)
|
||||
* 4. Allocate credits to employees
|
||||
* 5. Employee uses allocated credits with org tracking
|
||||
* 6. Track organization-wide usage
|
||||
* 7. Multi-org switching (future)
|
||||
*
|
||||
* NOTE: Organization registration via Better Auth is not yet fully integrated.
|
||||
* For now, we simulate organization creation by directly inserting into the database.
|
||||
* These tests will be updated when Better Auth organization plugin is fully integrated.
|
||||
*/
|
||||
|
||||
import { Test } from '@nestjs/testing';
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
import { AppModule } from '../../src/app.module';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { getDb } from '../../src/db/connection';
|
||||
import { organizations, members } from '../../src/db/schema';
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
// Helper to generate random IDs (avoiding nanoid ESM issues in Jest)
|
||||
const generateId = (length = 16): string => {
|
||||
return randomBytes(Math.ceil(length / 2))
|
||||
.toString('hex')
|
||||
.slice(0, length);
|
||||
};
|
||||
|
||||
describe('B2B Organization Journey (E2E)', () => {
|
||||
let app: INestApplication;
|
||||
let ownerToken: string;
|
||||
let employeeToken: string;
|
||||
let employee2Token: string;
|
||||
let organizationId: string;
|
||||
let ownerId: string;
|
||||
let employeeId: string;
|
||||
let employee2Id: string;
|
||||
let configService: ConfigService;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
configService = app.get(ConfigService);
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe('Phase 1: Organization Registration', () => {
|
||||
const uniqueTimestamp = Date.now();
|
||||
const ownerEmail = `b2b-owner-${uniqueTimestamp}@company.com`;
|
||||
const ownerPassword = 'SecurePassword123!';
|
||||
const organizationName = `Test Corp ${uniqueTimestamp}`;
|
||||
|
||||
it('should register organization owner user', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/auth/register')
|
||||
.send({
|
||||
email: ownerEmail,
|
||||
password: ownerPassword,
|
||||
name: 'John Owner',
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
expect(response.body).toMatchObject({
|
||||
id: expect.any(String),
|
||||
email: ownerEmail,
|
||||
name: 'John Owner',
|
||||
});
|
||||
|
||||
ownerId = response.body.id;
|
||||
});
|
||||
|
||||
it('should login as owner and receive tokens', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/auth/login')
|
||||
.send({
|
||||
email: ownerEmail,
|
||||
password: ownerPassword,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toMatchObject({
|
||||
user: {
|
||||
id: ownerId,
|
||||
email: ownerEmail,
|
||||
},
|
||||
accessToken: expect.any(String),
|
||||
refreshToken: expect.any(String),
|
||||
});
|
||||
|
||||
ownerToken = response.body.accessToken;
|
||||
});
|
||||
|
||||
it('should create organization and add owner as member (simulated)', async () => {
|
||||
// NOTE: This simulates what Better Auth organization plugin would do
|
||||
// When Better Auth is integrated, this will be replaced with:
|
||||
// POST /auth/register-b2b endpoint
|
||||
|
||||
const databaseUrl = configService.get<string>('database.url');
|
||||
const db = getDb(databaseUrl!);
|
||||
|
||||
// Create organization
|
||||
const orgId = generateId(16);
|
||||
const slug = organizationName.toLowerCase().replace(/\s+/g, '-');
|
||||
|
||||
const [org] = await db
|
||||
.insert(organizations)
|
||||
.values({
|
||||
id: orgId,
|
||||
name: organizationName,
|
||||
slug,
|
||||
})
|
||||
.returning();
|
||||
|
||||
organizationId = org.id;
|
||||
|
||||
// Add owner as member with 'owner' role
|
||||
const [member] = await db
|
||||
.insert(members)
|
||||
.values({
|
||||
id: generateId(16),
|
||||
organizationId,
|
||||
userId: ownerId,
|
||||
role: 'owner',
|
||||
})
|
||||
.returning();
|
||||
|
||||
expect(org).toMatchObject({
|
||||
id: organizationId,
|
||||
name: organizationName,
|
||||
slug,
|
||||
});
|
||||
|
||||
expect(member).toMatchObject({
|
||||
organizationId,
|
||||
userId: ownerId,
|
||||
role: 'owner',
|
||||
});
|
||||
});
|
||||
|
||||
it('should verify organization credit balance is initialized', async () => {
|
||||
const databaseUrl = configService.get<string>('database.url');
|
||||
const db = getDb(databaseUrl!);
|
||||
|
||||
// Manually initialize org balance (would be automatic with Better Auth)
|
||||
const { createOrganizationCreditBalance } = await import(
|
||||
'../../src/credits/credits.service'
|
||||
).then((module) => {
|
||||
const CreditsService = module.CreditsService;
|
||||
const service = new CreditsService(configService);
|
||||
return {
|
||||
createOrganizationCreditBalance: (orgId: string) =>
|
||||
service['createOrganizationCreditBalance'](orgId),
|
||||
};
|
||||
});
|
||||
|
||||
await createOrganizationCreditBalance(organizationId);
|
||||
|
||||
// Verify organization balance
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/credits/organization/${organizationId}/balance`)
|
||||
.set('Authorization', `Bearer ${ownerToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toMatchObject({
|
||||
balance: 0,
|
||||
allocatedCredits: 0,
|
||||
availableCredits: 0,
|
||||
totalPurchased: 0,
|
||||
totalAllocated: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should verify owner has personal credit balance', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get('/credits/balance')
|
||||
.set('Authorization', `Bearer ${ownerToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toMatchObject({
|
||||
balance: 0,
|
||||
freeCreditsRemaining: 150, // Signup bonus
|
||||
totalSpent: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Phase 2: Employee Onboarding', () => {
|
||||
const employeeEmail = `b2b-employee-${Date.now()}@company.com`;
|
||||
const employee2Email = `b2b-employee2-${Date.now()}@company.com`;
|
||||
const employeePassword = 'SecurePassword123!';
|
||||
|
||||
it('should register first employee user', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/auth/register')
|
||||
.send({
|
||||
email: employeeEmail,
|
||||
password: employeePassword,
|
||||
name: 'Jane Employee',
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
expect(response.body.email).toBe(employeeEmail);
|
||||
employeeId = response.body.id;
|
||||
});
|
||||
|
||||
it('should login as employee', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/auth/login')
|
||||
.send({
|
||||
email: employeeEmail,
|
||||
password: employeePassword,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
employeeToken = response.body.accessToken;
|
||||
});
|
||||
|
||||
it('should add employee to organization (simulated invitation acceptance)', async () => {
|
||||
// NOTE: This simulates what Better Auth organization plugin would do
|
||||
// When Better Auth is integrated, this will be:
|
||||
// 1. POST /auth/organization/invite (by owner)
|
||||
// 2. POST /auth/organization/accept-invitation (by employee)
|
||||
|
||||
const databaseUrl = configService.get<string>('database.url');
|
||||
const db = getDb(databaseUrl!);
|
||||
|
||||
const [member] = await db
|
||||
.insert(members)
|
||||
.values({
|
||||
id: generateId(16),
|
||||
organizationId,
|
||||
userId: employeeId,
|
||||
role: 'member',
|
||||
})
|
||||
.returning();
|
||||
|
||||
expect(member).toMatchObject({
|
||||
organizationId,
|
||||
userId: employeeId,
|
||||
role: 'member',
|
||||
});
|
||||
});
|
||||
|
||||
it('should register second employee user', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/auth/register')
|
||||
.send({
|
||||
email: employee2Email,
|
||||
password: employeePassword,
|
||||
name: 'Bob Employee',
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
employee2Id = response.body.id;
|
||||
});
|
||||
|
||||
it('should login as second employee', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/auth/login')
|
||||
.send({
|
||||
email: employee2Email,
|
||||
password: employeePassword,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
employee2Token = response.body.accessToken;
|
||||
});
|
||||
|
||||
it('should add second employee to organization', async () => {
|
||||
const databaseUrl = configService.get<string>('database.url');
|
||||
const db = getDb(databaseUrl!);
|
||||
|
||||
await db.insert(members).values({
|
||||
id: generateId(16),
|
||||
organizationId,
|
||||
userId: employee2Id,
|
||||
role: 'member',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Phase 3: Credit Allocation', () => {
|
||||
it('should give organization some credits (simulated purchase)', async () => {
|
||||
// Simulate organization purchasing 10,000 credits
|
||||
const databaseUrl = configService.get<string>('database.url');
|
||||
const db = getDb(databaseUrl!);
|
||||
const { organizationBalances } = await import('../../src/db/schema');
|
||||
const { eq } = await import('drizzle-orm');
|
||||
|
||||
await db
|
||||
.update(organizationBalances)
|
||||
.set({
|
||||
balance: 10000,
|
||||
totalPurchased: 10000,
|
||||
availableCredits: 10000,
|
||||
})
|
||||
.where(eq(organizationBalances.organizationId, organizationId));
|
||||
|
||||
// Verify update
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/credits/organization/${organizationId}/balance`)
|
||||
.set('Authorization', `Bearer ${ownerToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.balance).toBe(10000);
|
||||
expect(response.body.availableCredits).toBe(10000);
|
||||
});
|
||||
|
||||
it('should allow owner to allocate credits to employee', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/credits/organization/allocate')
|
||||
.set('Authorization', `Bearer ${ownerToken}`)
|
||||
.send({
|
||||
organizationId,
|
||||
employeeId,
|
||||
amount: 500,
|
||||
reason: 'Monthly allocation',
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toMatchObject({
|
||||
success: true,
|
||||
allocation: {
|
||||
organizationId,
|
||||
employeeId,
|
||||
amount: 500,
|
||||
reason: 'Monthly allocation',
|
||||
allocatedBy: ownerId,
|
||||
},
|
||||
organizationBalance: {
|
||||
balance: 10000,
|
||||
allocatedCredits: 500,
|
||||
availableCredits: 9500,
|
||||
},
|
||||
employeeBalance: {
|
||||
balance: 500,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should verify employee balance increased', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get('/credits/balance')
|
||||
.set('Authorization', `Bearer ${employeeToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toMatchObject({
|
||||
balance: 500, // Allocated credits
|
||||
freeCreditsRemaining: 150, // Still has signup bonus
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow owner to allocate to second employee', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/credits/organization/allocate')
|
||||
.set('Authorization', `Bearer ${ownerToken}`)
|
||||
.send({
|
||||
organizationId,
|
||||
employeeId: employee2Id,
|
||||
amount: 300,
|
||||
reason: 'Initial allocation',
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.employeeBalance.balance).toBe(300);
|
||||
});
|
||||
|
||||
it('should verify organization available credits reduced correctly', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/credits/organization/${organizationId}/balance`)
|
||||
.set('Authorization', `Bearer ${ownerToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toMatchObject({
|
||||
balance: 10000,
|
||||
allocatedCredits: 800, // 500 + 300
|
||||
availableCredits: 9200, // 10000 - 800
|
||||
totalAllocated: 800,
|
||||
});
|
||||
});
|
||||
|
||||
it('should prevent non-owner from allocating credits', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/credits/organization/allocate')
|
||||
.set('Authorization', `Bearer ${employeeToken}`)
|
||||
.send({
|
||||
organizationId,
|
||||
employeeId: employee2Id,
|
||||
amount: 100,
|
||||
reason: 'Unauthorized allocation attempt',
|
||||
})
|
||||
.expect(403);
|
||||
|
||||
expect(response.body.message).toContain('Only organization owners can allocate credits');
|
||||
});
|
||||
|
||||
it('should prevent allocation exceeding available credits', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/credits/organization/allocate')
|
||||
.set('Authorization', `Bearer ${ownerToken}`)
|
||||
.send({
|
||||
organizationId,
|
||||
employeeId,
|
||||
amount: 10000, // More than available (9200)
|
||||
reason: 'Exceeding available',
|
||||
})
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.message).toContain('Insufficient organization credits');
|
||||
});
|
||||
|
||||
it('should prevent negative credit allocation', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/credits/organization/allocate')
|
||||
.set('Authorization', `Bearer ${ownerToken}`)
|
||||
.send({
|
||||
organizationId,
|
||||
employeeId,
|
||||
amount: -100,
|
||||
reason: 'Negative allocation',
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('should show recent allocations in organization balance', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/credits/organization/${organizationId}/balance`)
|
||||
.set('Authorization', `Bearer ${ownerToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.recentAllocations).toBeDefined();
|
||||
expect(Array.isArray(response.body.recentAllocations)).toBe(true);
|
||||
expect(response.body.recentAllocations.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Most recent should be the second employee allocation
|
||||
const mostRecent = response.body.recentAllocations[0];
|
||||
expect(mostRecent).toMatchObject({
|
||||
organizationId,
|
||||
employeeId: employee2Id,
|
||||
amount: 300,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Phase 4: Employee Credit Usage with Organization Tracking', () => {
|
||||
it('should allow employee to use allocated credits with org tracking', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/credits/organization/${organizationId}/use`)
|
||||
.set('Authorization', `Bearer ${employeeToken}`)
|
||||
.send({
|
||||
amount: 50,
|
||||
appId: 'chat',
|
||||
description: 'AI chat conversation',
|
||||
metadata: {
|
||||
messageCount: 10,
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toMatchObject({
|
||||
success: true,
|
||||
transaction: {
|
||||
userId: employeeId,
|
||||
type: 'usage',
|
||||
amount: -50,
|
||||
appId: 'chat',
|
||||
organizationId, // Critical: organization ID should be tracked
|
||||
},
|
||||
newBalance: {
|
||||
balance: 450, // 500 - 50
|
||||
freeCreditsRemaining: 150, // Unchanged (uses paid credits first)
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should verify transaction includes organization_id', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get('/credits/transactions')
|
||||
.set('Authorization', `Bearer ${employeeToken}`)
|
||||
.expect(200);
|
||||
|
||||
// Find the usage transaction we just made
|
||||
const usageTransaction = response.body.find(
|
||||
(t: any) => t.type === 'usage' && t.amount === -50
|
||||
);
|
||||
|
||||
expect(usageTransaction).toBeDefined();
|
||||
expect(usageTransaction.organizationId).toBe(organizationId);
|
||||
expect(usageTransaction.appId).toBe('chat');
|
||||
});
|
||||
|
||||
it('should allow second employee to use credits', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/credits/organization/${organizationId}/use`)
|
||||
.set('Authorization', `Bearer ${employee2Token}`)
|
||||
.send({
|
||||
amount: 75,
|
||||
appId: 'picture',
|
||||
description: 'Image generation',
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.newBalance.balance).toBe(225); // 300 - 75
|
||||
expect(response.body.transaction.organizationId).toBe(organizationId);
|
||||
});
|
||||
|
||||
it('should use free credits before allocated credits', async () => {
|
||||
// Employee currently has: 450 paid credits + 150 free credits
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/credits/organization/${organizationId}/use`)
|
||||
.set('Authorization', `Bearer ${employeeToken}`)
|
||||
.send({
|
||||
amount: 100,
|
||||
appId: 'memoro',
|
||||
description: 'Audio transcription',
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.newBalance).toMatchObject({
|
||||
balance: 450, // Unchanged (used free credits)
|
||||
freeCreditsRemaining: 50, // 150 - 100
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle using more than free credits', async () => {
|
||||
// Employee now has: 450 paid + 50 free
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/credits/organization/${organizationId}/use`)
|
||||
.set('Authorization', `Bearer ${employeeToken}`)
|
||||
.send({
|
||||
amount: 200, // Will use all 50 free + 150 paid
|
||||
appId: 'wisekeep',
|
||||
description: 'Video analysis',
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.newBalance).toMatchObject({
|
||||
balance: 300, // 450 - 150
|
||||
freeCreditsRemaining: 0, // All free credits used
|
||||
});
|
||||
});
|
||||
|
||||
it('should prevent employee from using more credits than available', async () => {
|
||||
// Employee now has: 300 paid + 0 free = 300 total
|
||||
await request(app.getHttpServer())
|
||||
.post(`/credits/organization/${organizationId}/use`)
|
||||
.set('Authorization', `Bearer ${employeeToken}`)
|
||||
.send({
|
||||
amount: 500, // More than available
|
||||
appId: 'chat',
|
||||
description: 'Should fail',
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('should track all employee usage in transaction history', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get('/credits/transactions')
|
||||
.set('Authorization', `Bearer ${employeeToken}`)
|
||||
.expect(200);
|
||||
|
||||
// Filter to just usage transactions with org tracking
|
||||
const orgUsage = response.body.filter(
|
||||
(t: any) => t.type === 'usage' && t.organizationId === organizationId
|
||||
);
|
||||
|
||||
expect(orgUsage.length).toBeGreaterThanOrEqual(4);
|
||||
|
||||
// All should have organizationId
|
||||
orgUsage.forEach((transaction: any) => {
|
||||
expect(transaction.organizationId).toBe(organizationId);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Phase 5: Organization Balance & Analytics', () => {
|
||||
it('should show accurate organization balance after employee usage', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/credits/organization/${organizationId}/balance`)
|
||||
.set('Authorization', `Bearer ${ownerToken}`)
|
||||
.expect(200);
|
||||
|
||||
// Organization balance should be unchanged (employees used their allocated credits)
|
||||
expect(response.body).toMatchObject({
|
||||
balance: 10000,
|
||||
allocatedCredits: 800, // Still 800 allocated
|
||||
availableCredits: 9200, // Still 9200 available
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow additional allocation after usage', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/credits/organization/allocate')
|
||||
.set('Authorization', `Bearer ${ownerToken}`)
|
||||
.send({
|
||||
organizationId,
|
||||
employeeId,
|
||||
amount: 1000,
|
||||
reason: 'Additional allocation after usage',
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.organizationBalance.allocatedCredits).toBe(1800); // 800 + 1000
|
||||
expect(response.body.organizationBalance.availableCredits).toBe(8200); // 9200 - 1000
|
||||
});
|
||||
|
||||
it('should verify employee received additional allocation', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get('/credits/balance')
|
||||
.set('Authorization', `Bearer ${employeeToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.balance).toBe(1300); // 300 + 1000
|
||||
});
|
||||
|
||||
it('should get employee balance within organization context', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/credits/organization/${organizationId}/employee/${employeeId}/balance`)
|
||||
.set('Authorization', `Bearer ${ownerToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toMatchObject({
|
||||
balance: 1300,
|
||||
freeCreditsRemaining: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Phase 6: Edge Cases & Security', () => {
|
||||
it('should prevent allocating to non-existent employee', async () => {
|
||||
const fakeEmployeeId = '00000000-0000-0000-0000-000000000000';
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post('/credits/organization/allocate')
|
||||
.set('Authorization', `Bearer ${ownerToken}`)
|
||||
.send({
|
||||
organizationId,
|
||||
employeeId: fakeEmployeeId,
|
||||
amount: 100,
|
||||
reason: 'Allocation to non-existent user',
|
||||
})
|
||||
.expect(400); // Will fail when trying to create balance
|
||||
});
|
||||
|
||||
it('should prevent using credits with wrong organization ID', async () => {
|
||||
const fakeOrgId = 'fake-org-id-12345';
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post(`/credits/organization/${fakeOrgId}/use`)
|
||||
.set('Authorization', `Bearer ${employeeToken}`)
|
||||
.send({
|
||||
amount: 10,
|
||||
appId: 'chat',
|
||||
description: 'Wrong org usage',
|
||||
})
|
||||
.expect(200); // Currently succeeds but tracks wrong org ID
|
||||
// TODO: Add validation to check user is member of organization
|
||||
});
|
||||
|
||||
it('should handle concurrent allocation requests safely', async () => {
|
||||
const requests = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
requests.push(
|
||||
request(app.getHttpServer())
|
||||
.post('/credits/organization/allocate')
|
||||
.set('Authorization', `Bearer ${ownerToken}`)
|
||||
.send({
|
||||
organizationId,
|
||||
employeeId: employee2Id,
|
||||
amount: 100,
|
||||
reason: `Concurrent allocation ${i}`,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const responses = await Promise.all(requests);
|
||||
|
||||
// All should either succeed or conflict
|
||||
responses.forEach((response) => {
|
||||
expect([200, 409]).toContain(response.status);
|
||||
});
|
||||
});
|
||||
|
||||
it('should validate allocation DTO', async () => {
|
||||
// Missing required fields
|
||||
await request(app.getHttpServer())
|
||||
.post('/credits/organization/allocate')
|
||||
.set('Authorization', `Bearer ${ownerToken}`)
|
||||
.send({
|
||||
organizationId,
|
||||
// Missing employeeId and amount
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('should require authentication for allocation endpoint', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/credits/organization/allocate')
|
||||
.send({
|
||||
organizationId,
|
||||
employeeId,
|
||||
amount: 100,
|
||||
reason: 'No auth',
|
||||
})
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it('should require authentication for org balance endpoint', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get(`/credits/organization/${organizationId}/balance`)
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Phase 7: Transaction Idempotency', () => {
|
||||
it('should support idempotent credit usage with org tracking', async () => {
|
||||
const idempotencyKey = `org-idempotent-${Date.now()}`;
|
||||
|
||||
// First request
|
||||
const response1 = await request(app.getHttpServer())
|
||||
.post(`/credits/organization/${organizationId}/use`)
|
||||
.set('Authorization', `Bearer ${employeeToken}`)
|
||||
.send({
|
||||
amount: 25,
|
||||
appId: 'test',
|
||||
description: 'Idempotency test with org',
|
||||
idempotencyKey,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const balanceAfterFirst = response1.body.newBalance.balance;
|
||||
|
||||
// Second request with same idempotency key
|
||||
const response2 = await request(app.getHttpServer())
|
||||
.post(`/credits/organization/${organizationId}/use`)
|
||||
.set('Authorization', `Bearer ${employeeToken}`)
|
||||
.send({
|
||||
amount: 25,
|
||||
appId: 'test',
|
||||
description: 'Idempotency test with org',
|
||||
idempotencyKey,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response2.body.message).toBe('Transaction already processed');
|
||||
|
||||
// Verify balance unchanged
|
||||
const balanceCheck = await request(app.getHttpServer())
|
||||
.get('/credits/balance')
|
||||
.set('Authorization', `Bearer ${employeeToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(balanceCheck.body.balance).toBe(balanceAfterFirst);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Phase 8: Complete Organization Workflow', () => {
|
||||
it('should demonstrate complete B2B flow summary', async () => {
|
||||
// Get final organization balance
|
||||
const orgBalance = await request(app.getHttpServer())
|
||||
.get(`/credits/organization/${organizationId}/balance`)
|
||||
.set('Authorization', `Bearer ${ownerToken}`)
|
||||
.expect(200);
|
||||
|
||||
// Get employee balances
|
||||
const employee1Balance = await request(app.getHttpServer())
|
||||
.get('/credits/balance')
|
||||
.set('Authorization', `Bearer ${employeeToken}`)
|
||||
.expect(200);
|
||||
|
||||
const employee2Balance = await request(app.getHttpServer())
|
||||
.get('/credits/balance')
|
||||
.set('Authorization', `Bearer ${employee2Token}`)
|
||||
.expect(200);
|
||||
|
||||
// Verify final state
|
||||
expect(orgBalance.body.balance).toBe(10000); // Total purchased
|
||||
expect(orgBalance.body.totalAllocated).toBeGreaterThan(0);
|
||||
expect(orgBalance.body.availableCredits).toBeLessThan(10000);
|
||||
|
||||
expect(employee1Balance.body.balance).toBeGreaterThan(0);
|
||||
expect(employee2Balance.body.balance).toBeGreaterThan(0);
|
||||
|
||||
// Log summary for visibility
|
||||
console.log('\n=== B2B Journey Summary ===');
|
||||
console.log('Organization Balance:', orgBalance.body);
|
||||
console.log('Employee 1 Balance:', employee1Balance.body);
|
||||
console.log('Employee 2 Balance:', employee2Balance.body);
|
||||
console.log('===========================\n');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('B2B Organization Journey - Future Features', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe('Multi-Organization Switching (Future)', () => {
|
||||
it.skip('should allow user to belong to multiple organizations', async () => {
|
||||
// Future: Test user with multiple org memberships
|
||||
// 1. User is member of Org A and Org B
|
||||
// 2. User can view all organizations they belong to
|
||||
// 3. User has separate credit balances for each org
|
||||
});
|
||||
|
||||
it.skip('should switch active organization and update JWT claims', async () => {
|
||||
// Future: Test setActiveOrganization
|
||||
// POST /auth/organization/set-active
|
||||
// - Switch from Org A to Org B
|
||||
// - JWT should update with new organization context
|
||||
// - Credit operations should use new organization
|
||||
});
|
||||
|
||||
it.skip('should include correct organization in JWT claims', async () => {
|
||||
// Future: Verify JWT payload structure for B2B users
|
||||
// JWT should contain:
|
||||
// {
|
||||
// sub: "user-123",
|
||||
// email: "employee@acme.com",
|
||||
// role: "user",
|
||||
// customer_type: "b2b",
|
||||
// organization: {
|
||||
// id: "org-789",
|
||||
// name: "Acme Corp",
|
||||
// role: "member"
|
||||
// },
|
||||
// credit_balance: 500
|
||||
// }
|
||||
});
|
||||
});
|
||||
|
||||
describe('Email Invitation Flow (Future)', () => {
|
||||
it.skip('should send invitation email when owner invites employee', async () => {
|
||||
// Future: Test email sending integration
|
||||
// POST /auth/organization/invite
|
||||
// - Email sent to employee@example.com
|
||||
// - Email contains invitation link with token
|
||||
// - Invitation expires after 7 days
|
||||
});
|
||||
|
||||
it.skip('should allow employee to register via invitation link', async () => {
|
||||
// Future: Test invitation acceptance
|
||||
// GET /auth/invitation/{token}
|
||||
// - Employee clicks link, creates account
|
||||
// - Automatically added to organization
|
||||
// - Personal balance initialized
|
||||
});
|
||||
|
||||
it.skip('should handle invitation to existing user', async () => {
|
||||
// Future: Test invitation to existing email
|
||||
// - User already has account
|
||||
// - Click invitation link -> auto-accept
|
||||
// - Added to organization, no new account created
|
||||
});
|
||||
});
|
||||
|
||||
describe('Advanced Permission System (Future)', () => {
|
||||
it.skip('should allow admins to invite but not allocate credits', async () => {
|
||||
// Future: Test role-based permissions
|
||||
// - Admin can POST /auth/organization/invite
|
||||
// - Admin cannot POST /credits/organization/allocate
|
||||
});
|
||||
|
||||
it.skip('should allow members to view but not manage', async () => {
|
||||
// Future: Test member permissions
|
||||
// - Member can GET /credits/organization/:id/balance
|
||||
// - Member cannot POST /auth/organization/invite
|
||||
// - Member cannot POST /credits/organization/allocate
|
||||
});
|
||||
|
||||
it.skip('should prevent removed members from accessing organization', async () => {
|
||||
// Future: Test member removal
|
||||
// DELETE /auth/organization/members/{memberId}
|
||||
// - Member can no longer access org resources
|
||||
// - Member's allocated credits are revoked
|
||||
// - Transaction history preserved
|
||||
});
|
||||
});
|
||||
|
||||
describe('Organization Purchase Flow (Future)', () => {
|
||||
it.skip('should allow organization to purchase credits via Stripe', async () => {
|
||||
// Future: Test B2B purchase flow
|
||||
// POST /credits/organization/purchase
|
||||
// - Organization owner purchases 10,000 credits
|
||||
// - Stripe payment succeeds
|
||||
// - Organization balance updated
|
||||
// - Purchase recorded in history
|
||||
});
|
||||
|
||||
it.skip('should handle failed organization purchases', async () => {
|
||||
// Future: Test payment failure
|
||||
// - Stripe payment fails
|
||||
// - Organization balance unchanged
|
||||
// - Purchase marked as failed
|
||||
});
|
||||
});
|
||||
|
||||
describe('Analytics & Reporting (Future)', () => {
|
||||
it.skip('should provide organization-wide usage statistics', async () => {
|
||||
// Future: Test analytics endpoint
|
||||
// GET /credits/organization/:id/analytics?period=30d
|
||||
// - Total credits used by all employees
|
||||
// - Breakdown by app (chat, picture, memoro, etc.)
|
||||
// - Breakdown by employee
|
||||
// - Usage trends over time
|
||||
});
|
||||
|
||||
it.skip('should export organization transaction history', async () => {
|
||||
// Future: Test export functionality
|
||||
// GET /credits/organization/:id/export?format=csv
|
||||
// - Download CSV of all transactions
|
||||
// - Include employee names, dates, apps, amounts
|
||||
});
|
||||
});
|
||||
|
||||
describe('Credit Reclamation (Future)', () => {
|
||||
it.skip('should allow owner to reclaim unused credits from employee', async () => {
|
||||
// Future: Test credit reclamation
|
||||
// POST /credits/organization/reclaim
|
||||
// - Owner takes back 200 credits from employee
|
||||
// - Employee balance reduced
|
||||
// - Organization available credits increased
|
||||
// - Reclamation recorded in allocation history
|
||||
});
|
||||
|
||||
it.skip('should prevent reclaiming more than employee has', async () => {
|
||||
// Future: Validation test
|
||||
// - Employee has 100 credits
|
||||
// - Owner tries to reclaim 200 credits
|
||||
// - Request fails with appropriate error
|
||||
});
|
||||
});
|
||||
});
|
||||
624
services/mana-core-auth/test/e2e/guild-journey.e2e-spec.ts
Normal file
624
services/mana-core-auth/test/e2e/guild-journey.e2e-spec.ts
Normal file
|
|
@ -0,0 +1,624 @@
|
|||
/**
|
||||
* Guild (Gilde) Journey E2E Tests
|
||||
*
|
||||
* Complete end-to-end test for Guild workflows:
|
||||
* 1. Create guild with pool
|
||||
* 2. Invite and onboard members
|
||||
* 3. Fund guild pool from personal balance
|
||||
* 4. Members use credits from pool
|
||||
* 5. Spending limits enforcement
|
||||
* 6. Credit source routing (personal vs guild)
|
||||
* 7. Member removal and access control
|
||||
* 8. Edge cases (concurrent, idempotency, insufficient funds)
|
||||
*/
|
||||
|
||||
import { Test } from '@nestjs/testing';
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
import { AppModule } from '../../src/app.module';
|
||||
|
||||
describe('Guild Journey (E2E)', () => {
|
||||
let app: INestApplication;
|
||||
let gildenmeisterToken: string;
|
||||
let memberToken: string;
|
||||
let gildenmesterId: string;
|
||||
let memberId: string;
|
||||
let guildId: string;
|
||||
|
||||
const uniqueTimestamp = Date.now();
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
app.useGlobalPipes(new ValidationPipe({ transform: true }));
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Phase 1: Guild Creation
|
||||
// =========================================================================
|
||||
|
||||
describe('Phase 1: Guild Creation', () => {
|
||||
const ownerEmail = `gildenmeister-${uniqueTimestamp}@test.com`;
|
||||
const ownerPassword = 'SecurePassword123!';
|
||||
|
||||
it('should register the Gildenmeister', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/auth/register')
|
||||
.send({
|
||||
email: ownerEmail,
|
||||
password: ownerPassword,
|
||||
name: 'Gildenmeister Max',
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
gildenmesterId = response.body.id;
|
||||
});
|
||||
|
||||
it('should login as Gildenmeister', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/auth/login')
|
||||
.send({
|
||||
email: ownerEmail,
|
||||
password: ownerPassword,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
gildenmeisterToken = response.body.accessToken;
|
||||
expect(gildenmeisterToken).toBeDefined();
|
||||
});
|
||||
|
||||
it('should create a guild via POST /gilden', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/gilden')
|
||||
.set('Authorization', `Bearer ${gildenmeisterToken}`)
|
||||
.send({
|
||||
name: `Testgilde ${uniqueTimestamp}`,
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
expect(response.body.gilde).toBeDefined();
|
||||
expect(response.body.gilde.name).toBe(`Testgilde ${uniqueTimestamp}`);
|
||||
expect(response.body.pool).toBeDefined();
|
||||
expect(response.body.pool.balance).toBe(0);
|
||||
|
||||
guildId = response.body.gilde.id;
|
||||
});
|
||||
|
||||
it('should show guild pool balance of 0', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/credits/guild/${guildId}/balance`)
|
||||
.set('Authorization', `Bearer ${gildenmeisterToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toMatchObject({
|
||||
balance: 0,
|
||||
totalFunded: 0,
|
||||
totalSpent: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should list the guild in user guilds', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get('/gilden')
|
||||
.set('Authorization', `Bearer ${gildenmeisterToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.guilds).toBeDefined();
|
||||
expect(response.body.guilds.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const guild = response.body.guilds.find((g: any) => g.gilde.id === guildId);
|
||||
expect(guild).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Phase 2: Member Management
|
||||
// =========================================================================
|
||||
|
||||
describe('Phase 2: Member Management', () => {
|
||||
const memberEmail = `gildenmitglied-${uniqueTimestamp}@test.com`;
|
||||
const memberPassword = 'SecurePassword123!';
|
||||
|
||||
it('should register a potential member', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/auth/register')
|
||||
.send({
|
||||
email: memberEmail,
|
||||
password: memberPassword,
|
||||
name: 'Mitglied Anna',
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
memberId = response.body.id;
|
||||
});
|
||||
|
||||
it('should login as member', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/auth/login')
|
||||
.send({
|
||||
email: memberEmail,
|
||||
password: memberPassword,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
memberToken = response.body.accessToken;
|
||||
});
|
||||
|
||||
it('should not allow non-member to access guild pool', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get(`/credits/guild/${guildId}/balance`)
|
||||
.set('Authorization', `Bearer ${memberToken}`)
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('should invite member to guild', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/gilden/${guildId}/invite`)
|
||||
.set('Authorization', `Bearer ${gildenmeisterToken}`)
|
||||
.send({
|
||||
email: memberEmail,
|
||||
role: 'member',
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
expect(response.body).toBeDefined();
|
||||
});
|
||||
|
||||
it('should list pending invitations for member', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get('/auth/invitations')
|
||||
.set('Authorization', `Bearer ${memberToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const invitation = response.body.find((inv: any) => inv.organizationId === guildId);
|
||||
expect(invitation).toBeDefined();
|
||||
|
||||
// Accept the invitation
|
||||
await request(app.getHttpServer())
|
||||
.post('/gilden/accept-invitation')
|
||||
.set('Authorization', `Bearer ${memberToken}`)
|
||||
.send({
|
||||
invitationId: invitation.id,
|
||||
})
|
||||
.expect(201);
|
||||
});
|
||||
|
||||
it('should now allow member to access guild pool', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/credits/guild/${guildId}/balance`)
|
||||
.set('Authorization', `Bearer ${memberToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.balance).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Phase 3: Pool Funding
|
||||
// =========================================================================
|
||||
|
||||
describe('Phase 3: Pool Funding', () => {
|
||||
it('should give Gildenmeister some personal credits (simulated)', async () => {
|
||||
// Purchase credits to personal balance first
|
||||
// We simulate this by directly adding credits via the use endpoint workaround
|
||||
// In a real scenario, this would be a Stripe purchase
|
||||
const { ConfigService } = await import('@nestjs/config');
|
||||
const configService = app.get(ConfigService);
|
||||
const databaseUrl = configService.get<string>('database.url');
|
||||
const { getDb } = await import('../../src/db/connection');
|
||||
const { balances } = await import('../../src/db/schema');
|
||||
const { eq } = await import('drizzle-orm');
|
||||
const db = getDb(databaseUrl!);
|
||||
|
||||
await db
|
||||
.update(balances)
|
||||
.set({
|
||||
balance: 5000,
|
||||
totalEarned: 5000,
|
||||
})
|
||||
.where(eq(balances.userId, gildenmesterId));
|
||||
|
||||
// Verify
|
||||
const response = await request(app.getHttpServer())
|
||||
.get('/credits/balance')
|
||||
.set('Authorization', `Bearer ${gildenmeisterToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.balance).toBe(5000);
|
||||
});
|
||||
|
||||
it('should fund guild pool from personal balance', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/credits/guild/${guildId}/fund`)
|
||||
.set('Authorization', `Bearer ${gildenmeisterToken}`)
|
||||
.send({
|
||||
amount: 2000,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.personalBalance.balance).toBe(3000); // 5000 - 2000
|
||||
expect(response.body.poolBalance.balance).toBe(2000);
|
||||
});
|
||||
|
||||
it('should verify guild pool balance increased', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/credits/guild/${guildId}/balance`)
|
||||
.set('Authorization', `Bearer ${gildenmeisterToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toMatchObject({
|
||||
balance: 2000,
|
||||
totalFunded: 2000,
|
||||
totalSpent: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should verify personal balance decreased', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get('/credits/balance')
|
||||
.set('Authorization', `Bearer ${gildenmeisterToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.balance).toBe(3000);
|
||||
});
|
||||
|
||||
it('should prevent member from funding (not owner/admin)', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post(`/credits/guild/${guildId}/fund`)
|
||||
.set('Authorization', `Bearer ${memberToken}`)
|
||||
.send({ amount: 100 })
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('should prevent funding more than personal balance', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post(`/credits/guild/${guildId}/fund`)
|
||||
.set('Authorization', `Bearer ${gildenmeisterToken}`)
|
||||
.send({ amount: 10000 })
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Phase 4: Credit Usage from Pool
|
||||
// =========================================================================
|
||||
|
||||
describe('Phase 4: Credit Usage from Pool', () => {
|
||||
it('should allow member to use credits from guild pool', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/credits/guild/${guildId}/use`)
|
||||
.set('Authorization', `Bearer ${memberToken}`)
|
||||
.send({
|
||||
amount: 50,
|
||||
appId: 'chat',
|
||||
description: 'AI chat conversation',
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.newBalance.balance).toBe(1950); // 2000 - 50
|
||||
expect(response.body.transaction.userId).toBe(memberId);
|
||||
expect(response.body.transaction.organizationId).toBe(guildId);
|
||||
});
|
||||
|
||||
it('should allow Gildenmeister to use credits from pool too', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/credits/guild/${guildId}/use`)
|
||||
.set('Authorization', `Bearer ${gildenmeisterToken}`)
|
||||
.send({
|
||||
amount: 100,
|
||||
appId: 'picture',
|
||||
description: 'Image generation',
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.newBalance.balance).toBe(1850); // 1950 - 100
|
||||
});
|
||||
|
||||
it('should track guild transactions', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/credits/guild/${guildId}/transactions`)
|
||||
.set('Authorization', `Bearer ${gildenmeisterToken}`)
|
||||
.expect(200);
|
||||
|
||||
// Owner should see all transactions (funding + 2 usages)
|
||||
expect(response.body.length).toBeGreaterThanOrEqual(3);
|
||||
|
||||
const usageTransactions = response.body.filter((t: any) => t.type === 'usage');
|
||||
expect(usageTransactions.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should only show own transactions to members', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/credits/guild/${guildId}/transactions`)
|
||||
.set('Authorization', `Bearer ${memberToken}`)
|
||||
.expect(200);
|
||||
|
||||
// Member should only see their own transactions
|
||||
response.body.forEach((t: any) => {
|
||||
expect(t.userId).toBe(memberId);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Phase 5: Credit Source Routing
|
||||
// =========================================================================
|
||||
|
||||
describe('Phase 5: Credit Source Routing', () => {
|
||||
it('should route to guild pool via POST /credits/use with creditSource', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/credits/use')
|
||||
.set('Authorization', `Bearer ${memberToken}`)
|
||||
.send({
|
||||
amount: 25,
|
||||
appId: 'todo',
|
||||
description: 'Task creation',
|
||||
creditSource: {
|
||||
type: 'guild',
|
||||
guildId,
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.newBalance.balance).toBe(1825); // 1850 - 25
|
||||
});
|
||||
|
||||
it('should use personal balance without creditSource', async () => {
|
||||
// Give member some personal credits first
|
||||
const { ConfigService } = await import('@nestjs/config');
|
||||
const configService = app.get(ConfigService);
|
||||
const databaseUrl = configService.get<string>('database.url');
|
||||
const { getDb } = await import('../../src/db/connection');
|
||||
const { balances } = await import('../../src/db/schema');
|
||||
const { eq } = await import('drizzle-orm');
|
||||
const db = getDb(databaseUrl!);
|
||||
|
||||
await db
|
||||
.update(balances)
|
||||
.set({ balance: 100, totalEarned: 100 })
|
||||
.where(eq(balances.userId, memberId));
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/credits/use')
|
||||
.set('Authorization', `Bearer ${memberToken}`)
|
||||
.send({
|
||||
amount: 10,
|
||||
appId: 'todo',
|
||||
description: 'Personal task',
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.success).toBe(true);
|
||||
// Should have deducted from personal balance, not guild
|
||||
expect(response.body.newBalance.balance).toBe(90); // 100 - 10
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Phase 6: Spending Limits
|
||||
// =========================================================================
|
||||
|
||||
describe('Phase 6: Spending Limits', () => {
|
||||
it('should set daily spending limit for member', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.put(`/credits/guild/${guildId}/members/${memberId}/limits`)
|
||||
.set('Authorization', `Bearer ${gildenmeisterToken}`)
|
||||
.send({
|
||||
dailyLimit: 100,
|
||||
monthlyLimit: 500,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.dailyLimit).toBe(100);
|
||||
expect(response.body.monthlyLimit).toBe(500);
|
||||
});
|
||||
|
||||
it('should get member spending limits', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/credits/guild/${guildId}/members/${memberId}/limits`)
|
||||
.set('Authorization', `Bearer ${memberToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.dailyLimit).toBe(100);
|
||||
expect(response.body.monthlyLimit).toBe(500);
|
||||
});
|
||||
|
||||
it('should get member spending summary', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/credits/guild/${guildId}/members/${memberId}/spending`)
|
||||
.set('Authorization', `Bearer ${memberToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.dailyLimit).toBe(100);
|
||||
expect(response.body.monthlyLimit).toBe(500);
|
||||
expect(response.body.spentToday).toBeGreaterThanOrEqual(0);
|
||||
expect(response.body.dailyRemaining).toBeDefined();
|
||||
});
|
||||
|
||||
it('should enforce daily spending limit', async () => {
|
||||
// Member already spent 75 today (50 + 25 from guild pool)
|
||||
// Daily limit is 100, so spending 50 more should fail
|
||||
await request(app.getHttpServer())
|
||||
.post(`/credits/guild/${guildId}/use`)
|
||||
.set('Authorization', `Bearer ${memberToken}`)
|
||||
.send({
|
||||
amount: 50,
|
||||
appId: 'chat',
|
||||
description: 'Should exceed daily limit',
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('should prevent member from setting their own limits', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.put(`/credits/guild/${guildId}/members/${memberId}/limits`)
|
||||
.set('Authorization', `Bearer ${memberToken}`)
|
||||
.send({ dailyLimit: 99999 })
|
||||
.expect(403);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Phase 7: Edge Cases & Security
|
||||
// =========================================================================
|
||||
|
||||
describe('Phase 7: Edge Cases & Security', () => {
|
||||
it('should prevent using more credits than pool has', async () => {
|
||||
// Remove limit first so we can test pool balance
|
||||
await request(app.getHttpServer())
|
||||
.put(`/credits/guild/${guildId}/members/${memberId}/limits`)
|
||||
.set('Authorization', `Bearer ${gildenmeisterToken}`)
|
||||
.send({ dailyLimit: null, monthlyLimit: null })
|
||||
.expect(200);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post(`/credits/guild/${guildId}/use`)
|
||||
.set('Authorization', `Bearer ${memberToken}`)
|
||||
.send({
|
||||
amount: 999999,
|
||||
appId: 'chat',
|
||||
description: 'Way too much',
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('should support idempotent guild credit usage', async () => {
|
||||
const idempotencyKey = `guild-idem-${Date.now()}`;
|
||||
|
||||
const response1 = await request(app.getHttpServer())
|
||||
.post(`/credits/guild/${guildId}/use`)
|
||||
.set('Authorization', `Bearer ${memberToken}`)
|
||||
.send({
|
||||
amount: 10,
|
||||
appId: 'test',
|
||||
description: 'Idempotency test',
|
||||
idempotencyKey,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
// Second request with same key
|
||||
const response2 = await request(app.getHttpServer())
|
||||
.post(`/credits/guild/${guildId}/use`)
|
||||
.set('Authorization', `Bearer ${memberToken}`)
|
||||
.send({
|
||||
amount: 10,
|
||||
appId: 'test',
|
||||
description: 'Idempotency test',
|
||||
idempotencyKey,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response2.body.message).toBe('Transaction already processed');
|
||||
});
|
||||
|
||||
it('should prevent non-member from using guild credits', async () => {
|
||||
// Register a random user not in the guild
|
||||
const randomEmail = `random-${Date.now()}@test.com`;
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post('/auth/register')
|
||||
.send({
|
||||
email: randomEmail,
|
||||
password: 'SecurePassword123!',
|
||||
name: 'Random User',
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
const loginRes = await request(app.getHttpServer())
|
||||
.post('/auth/login')
|
||||
.send({ email: randomEmail, password: 'SecurePassword123!' })
|
||||
.expect(200);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post(`/credits/guild/${guildId}/use`)
|
||||
.set('Authorization', `Bearer ${loginRes.body.accessToken}`)
|
||||
.send({
|
||||
amount: 10,
|
||||
appId: 'chat',
|
||||
description: 'Unauthorized',
|
||||
})
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('should prevent funding with negative amount', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post(`/credits/guild/${guildId}/fund`)
|
||||
.set('Authorization', `Bearer ${gildenmeisterToken}`)
|
||||
.send({ amount: -100 })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('should require authentication for all guild endpoints', async () => {
|
||||
await request(app.getHttpServer()).get(`/credits/guild/${guildId}/balance`).expect(401);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post(`/credits/guild/${guildId}/fund`)
|
||||
.send({ amount: 100 })
|
||||
.expect(401);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post(`/credits/guild/${guildId}/use`)
|
||||
.send({ amount: 10, appId: 'chat', description: 'test' })
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it('should handle concurrent guild spending safely', async () => {
|
||||
const requests = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
requests.push(
|
||||
request(app.getHttpServer())
|
||||
.post(`/credits/guild/${guildId}/use`)
|
||||
.set('Authorization', `Bearer ${memberToken}`)
|
||||
.send({
|
||||
amount: 5,
|
||||
appId: 'test',
|
||||
description: `Concurrent ${i}`,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const responses = await Promise.all(requests);
|
||||
|
||||
// All should either succeed or conflict
|
||||
responses.forEach((response) => {
|
||||
expect([200, 409]).toContain(response.status);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Phase 8: Final State Verification
|
||||
// =========================================================================
|
||||
|
||||
describe('Phase 8: Final State', () => {
|
||||
it('should show accurate final guild pool balance', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/credits/guild/${guildId}/balance`)
|
||||
.set('Authorization', `Bearer ${gildenmeisterToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.balance).toBeGreaterThanOrEqual(0);
|
||||
expect(response.body.totalFunded).toBe(2000);
|
||||
expect(response.body.totalSpent).toBeGreaterThan(0);
|
||||
|
||||
console.log('\n=== Guild Journey Summary ===');
|
||||
console.log('Pool Balance:', response.body);
|
||||
console.log('===========================\n');
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue