good forst commit

This commit is contained in:
2026-06-09 02:02:40 +05:30
parent 801c1d7121
commit 249d759e6a
215 changed files with 15425 additions and 1240 deletions
+12 -2
View File
@@ -48,13 +48,23 @@ describe('validateEnv', () => {
expect(result.WHATSAPP_SESSION_PATH).toBe('./sessions');
});
it('applies default TOWER_ADMIN_JIDS of empty string when not set', () => {
it('applies default TOWER_PORTAL_BASE_URL of http://localhost:3000 when not set', () => {
const env = {
DATABASE_URL: 'postgresql://user:pass@localhost:5432/db',
REDIS_URL: 'redis://localhost:6379',
JWT_SECRET: 'a'.repeat(32),
};
const result = validateEnv(env as NodeJS.ProcessEnv);
expect(result.TOWER_ADMIN_JIDS).toBe('');
expect(result.TOWER_PORTAL_BASE_URL).toBe('http://localhost:3000');
});
it('applies default MEMBER_JWT_EXPIRES_IN of 30d when not set', () => {
const env = {
DATABASE_URL: 'postgresql://user:pass@localhost:5432/db',
REDIS_URL: 'redis://localhost:6379',
JWT_SECRET: 'a'.repeat(32),
};
const result = validateEnv(env as NodeJS.ProcessEnv);
expect(result.MEMBER_JWT_EXPIRES_IN).toBe('30d');
});
});
+4 -1
View File
@@ -10,7 +10,10 @@ const envSchema = z.object({
MEILI_MASTER_KEY: z.string().default('tower_meili_dev_key'),
LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error']).default('info'),
WHATSAPP_SESSION_PATH: z.string().default('./sessions'),
TOWER_ADMIN_JIDS: z.string().default(''),
TOWER_PORTAL_BASE_URL: z.string().url().default('http://localhost:3000'),
BCRYPT_ROUNDS: z.coerce.number().int().min(4).max(15).default(10),
JWT_EXPIRES_IN: z.string().default('7d'),
MEMBER_JWT_EXPIRES_IN: z.string().default('30d'),
});
export type Env = z.infer<typeof envSchema>;
+3 -2
View File
@@ -26,13 +26,13 @@ describe('MESSAGES_INDEX', () => {
});
describe('configureIndex', () => {
it('sets searchable, filterable, and sortable attributes on the correct index', async () => {
it('sets searchable, filterable (including tenantId), and sortable attributes on the correct index', async () => {
const { client, mockIndex, mockUpdateSettings } = makeMockClient();
await configureIndex(client);
expect(mockIndex).toHaveBeenCalledWith('tower-messages');
expect(mockUpdateSettings).toHaveBeenCalledWith({
searchableAttributes: ['content', 'senderName', 'sourceGroupName'],
filterableAttributes: ['sourceGroupId', 'tags', 'platform'],
filterableAttributes: ['tenantId', 'sourceGroupId', 'tags', 'platform'],
sortableAttributes: ['approvedAt'],
});
});
@@ -43,6 +43,7 @@ describe('indexMessage', () => {
const { client, mockIndex, mockAddDocuments } = makeMockClient();
const doc: MeiliDocument = {
id: 'msg-1',
tenantId: 'tnt-1',
content: 'Hello community',
senderName: 'Alice',
sourceGroupId: 'grp-1',
+10 -2
View File
@@ -4,6 +4,7 @@ export { MeiliSearch } from 'meilisearch';
export interface MeiliDocument {
id: string; // DB Message.id
tenantId: string; // tenant owner — required for multi-tenant filter
content: string;
senderName: string; // empty string when null
sourceGroupId: string;
@@ -20,15 +21,22 @@ export function createMeiliClient(url: string, masterKey: string): MeiliSearch {
}
export async function configureIndex(client: MeiliSearch): Promise<void> {
// Ensure the index exists with the correct primary key
// (Meilisearch v1.11 can't infer it when multiple fields end with `id`)
try {
await client.getIndex(MESSAGES_INDEX);
} catch {
await client.createIndex(MESSAGES_INDEX, { primaryKey: 'id' });
}
await client.index(MESSAGES_INDEX).updateSettings({
searchableAttributes: ['content', 'senderName', 'sourceGroupName'],
filterableAttributes: ['sourceGroupId', 'tags', 'platform'],
filterableAttributes: ['tenantId', 'sourceGroupId', 'tags', 'platform'],
sortableAttributes: ['approvedAt'],
});
}
export async function indexMessage(client: MeiliSearch, doc: MeiliDocument): Promise<void> {
await client.index(MESSAGES_INDEX).addDocuments([doc]);
await client.index(MESSAGES_INDEX).addDocuments([doc], { primaryKey: 'id' });
}
export async function deleteMessage(client: MeiliSearch, id: string): Promise<void> {
+61
View File
@@ -0,0 +1,61 @@
export type AdminRole = 'OWNER' | 'ADMIN' | 'VIEWER';
export type JwtKind = 'admin' | 'member' | 'superadmin';
export interface AdminJwtPayload {
kind: 'admin';
sub: string;
tenantId: string;
role: AdminRole;
email: string;
}
export interface MemberJwtPayload {
kind: 'member';
sub: string;
tenantId: string;
jid: string;
phoneHash: string;
}
export interface SuperAdminJwtPayload {
kind: 'superadmin';
sub: string;
email: string;
}
export type JwtPayload = AdminJwtPayload | MemberJwtPayload | SuperAdminJwtPayload;
export interface LoginRequest {
tenantSlug?: string;
email: string;
password: string;
}
export interface LoginResponse {
token: string;
admin: {
id: string;
email: string;
role: AdminRole;
tenantId: string;
tenantSlug: string;
};
}
export interface SignupRequest {
tenantName: string;
tenantSlug: string;
email: string;
password: string;
}
export type SignupResponse = LoginResponse;
export interface AdminProfile {
id: string;
email: string;
role: AdminRole;
tenantId: string;
tenantSlug: string;
}
+35
View File
@@ -0,0 +1,35 @@
export type BotStatus = 'ACTIVE' | 'DISCONNECTED' | 'BANNED' | 'PAIRING';
export interface BotSummary {
id: string;
platform: string;
jid: string | null;
displayName: string | null;
status: BotStatus;
isBot: boolean;
createdAt: string;
updatedAt: string;
}
export interface BotInitiateResponse {
pairingToken: string;
expiresAt: string;
qrDataUrl: string | null;
}
export interface BotAttachResponse {
attached: true;
bot: BotSummary;
}
export interface BotQrResponse {
status: BotStatus;
qrDataUrl: string | null;
pairingToken: string;
expiresAt: string;
}
export interface BotRevealResponse {
jid: string;
revealedAt: string;
}
+4
View File
@@ -1 +1,5 @@
export * from './auth';
export * from './message';
export * from './bot';
export * from './onboarding';
export * from './rule';
+4
View File
@@ -59,6 +59,7 @@ export interface SyncRoute {
}
export interface IngestJobData {
tenantId: string;
platformMsgId: string;
platform: Platform;
accountId: string; // which bot account received this message
@@ -67,9 +68,11 @@ export interface IngestJobData {
senderName?: string;
content: string;
tags: string[];
effectiveAction?: 'FLAG' | 'AUTO_APPROVE' | 'SKIP' | 'REJECT';
}
export interface ForwardJobData {
tenantId: string;
messageId: string; // DB id of the approved Message record
content: string;
sourceGroupName: string;
@@ -79,6 +82,7 @@ export interface ForwardJobData {
}
export interface IndexJobData {
tenantId: string;
messageId: string; // DB Message.id (cuid)
content: string;
senderName: string | null;
+86
View File
@@ -0,0 +1,86 @@
export type ConsentScope = 'INGEST' | 'ARCHIVE' | 'REPLICATE' | 'DISPLAY';
export type ConsentStatus = 'GRANTED' | 'REVOKED';
export type MemberOptOutReason = 'STOP_KEYWORD' | 'SELF_PORTAL' | 'ADMIN_ACTION';
export interface OnboardingTokenPayload {
groupId: string;
jid: string;
tenantId: string;
iat?: number;
exp?: number;
}
export interface PublicOnboardInfo {
groupName: string;
tenantName: string;
policyVersion: string;
defaultScopes: ConsentScope[];
defaultRetentionDays: number;
}
export interface RequestOtpRequest {
onboardingToken: string;
phone: string;
}
export interface RequestOtpResponse {
ok: true;
challengeId: string;
expiresInSeconds: number;
}
export interface VerifyOtpRequest {
onboardingToken: string;
challengeId: string;
phone: string;
code: string;
scopes: ConsentScope[];
retentionDays?: number;
}
export interface VerifyOtpResponse {
memberToken: string;
user: {
id: string;
tenantId: string;
jid: string;
displayName: string | null;
};
consent: {
scopes: ConsentScope[];
retentionDays: number;
policyVersion: string;
};
}
export interface MemberProfile {
id: string;
tenantId: string;
jid: string;
displayName: string | null;
createdAt: string;
}
export interface MemberGroupSummary {
id: string;
name: string;
tenantId: string;
scopes: ConsentScope[];
retentionDays: number;
policyVersion: string;
consentStatus: ConsentStatus;
joinedAt: string;
}
export interface OptOutRequest {
groupId?: string;
scopes?: ConsentScope[];
reason?: MemberOptOutReason;
notes?: string;
}
export interface OptInRequest {
groupId: string;
scopes: ConsentScope[];
retentionDays?: number;
}
+31
View File
@@ -0,0 +1,31 @@
export type RuleMatchType = 'HASHTAG' | 'PREFIX' | 'REACTION_EMOJI';
export type RuleAction = 'FLAG' | 'AUTO_APPROVE' | 'SKIP' | 'REJECT';
export interface TenantRuleData {
id: string;
tenantId: string;
matchType: RuleMatchType;
matchValue: string;
action: RuleAction;
priority: number;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface CreateRuleRequest {
matchType: RuleMatchType;
matchValue: string;
action: RuleAction;
priority?: number;
isActive?: boolean;
}
export interface UpdateRuleRequest {
matchType?: RuleMatchType;
matchValue?: string;
action?: RuleAction;
priority?: number;
isActive?: boolean;
}