good forst commit
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1 +1,5 @@
|
||||
export * from './auth';
|
||||
export * from './message';
|
||||
export * from './bot';
|
||||
export * from './onboarding';
|
||||
export * from './rule';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user