Some checks are pending
Branch Check / Type Check & Build (push) Waiting to run
- server/utils/jwt.ts: дефолт JWT_ACCESS_EXPIRES 30d → 15m (env сохранено), hashToken() (sha256 hex) - migrations/0082_token_hashes.sql: *_hash колонки в user_sessions/users, DELETE FROM user_sessions (глобальный разлогин), legacy plain-колонки сохранены, но не пишутся - storage: lookup/отзыв сессий и reset-токенов по хэшу; markSessionReplaced по id сессии - auth.service: ротация в rotateFamilySession(), grace-period ротирует активную сессию вместо возврата plain-токена - auth.core.routes: forgot/reset-password пишут/ищут sha256-хэш, plain только в письме - tests/token-security.test.ts: 12 тестов (выпуск по хэшу, ротация, reuse detection, grace, reset) - .env.example, swagger, IMPLEMENTATION_LOG.md обновлены Проверки: npm run check чисто, vitest 118/118, lint 0 errors, build собирается
607 lines
21 KiB
TypeScript
607 lines
21 KiB
TypeScript
import { users, organizations, userSessions, invitations, userCustomFields, userCustomValues, roleMembers, forms, formFields, formStatuses, tasks, taskFieldValues, userOfflineSubscriptions, safeUserColumns, type User, type SafeUser, type Organization, type UserSession, type Invitation, type InsertUser, type InsertOrganization, type InsertUserSession, type InsertInvitation, type UserWithOrganization, type UserCustomField, type UserCustomValue, type CreateUserCustomField, type UpdateUserCustomField, type Task, type FormField, type FormStatus, type UserOfflineSubscription, type InsertUserOfflineSubscription } from "@shared/schema";
|
||
import { db } from "../db";
|
||
import { eq, and, desc, asc, sql, ilike, or, gte, lte, inArray, isNull } from "drizzle-orm";
|
||
import crypto from "crypto";
|
||
import { hashToken } from "../utils/jwt";
|
||
import { userStatusesStorage } from "./user-statuses.storage";
|
||
import { invalidateAccessibleTasksForUser } from "../utils/cache";
|
||
|
||
export interface ListUsersOptions {
|
||
search?: string;
|
||
roleId?: number;
|
||
appRole?: string;
|
||
isActive?: boolean;
|
||
sortBy?: 'name' | 'createdAt' | 'lastLogin';
|
||
order?: 'asc' | 'desc';
|
||
limit?: number;
|
||
offset?: number;
|
||
}
|
||
|
||
export interface ListUsersResult {
|
||
users: SafeUser[];
|
||
total: number;
|
||
}
|
||
|
||
export class UsersStorage {
|
||
// Organizations
|
||
async getOrganization(id: number): Promise<Organization | undefined> {
|
||
const [organization] = await db.select().from(organizations).where(eq(organizations.id, id));
|
||
return organization || undefined;
|
||
}
|
||
|
||
async getOrganizationBySlug(slug: string): Promise<Organization | undefined> {
|
||
const [organization] = await db.select().from(organizations).where(eq(organizations.slug, slug));
|
||
return organization || undefined;
|
||
}
|
||
|
||
async createOrganization(insertOrganization: InsertOrganization): Promise<Organization> {
|
||
const [organization] = await db
|
||
.insert(organizations)
|
||
.values(insertOrganization)
|
||
.returning();
|
||
return organization;
|
||
}
|
||
|
||
// Users
|
||
async getUser(id: number): Promise<User | undefined> {
|
||
const [user] = await db.select().from(users).where(eq(users.id, id));
|
||
return user || undefined;
|
||
}
|
||
|
||
async getUserWithOrganization(id: number): Promise<UserWithOrganization | undefined> {
|
||
const [result] = await db
|
||
.select()
|
||
.from(users)
|
||
.innerJoin(organizations, eq(users.organizationId, organizations.id))
|
||
.where(eq(users.id, id));
|
||
|
||
if (!result) return undefined;
|
||
|
||
return {
|
||
...result.users,
|
||
organization: result.organizations,
|
||
};
|
||
}
|
||
|
||
async getUserByEmail(email: string, organizationId?: number): Promise<User | undefined> {
|
||
const conditions = organizationId
|
||
? and(ilike(users.email, email), eq(users.organizationId, organizationId))
|
||
: ilike(users.email, email);
|
||
|
||
const [user] = await db.select().from(users).where(conditions);
|
||
return user || undefined;
|
||
}
|
||
|
||
async getUserByResetPasswordToken(token: string): Promise<User | undefined> {
|
||
// Шаг 0.6: поиск по sha256-хэшу, plain-токен в БД не хранится.
|
||
const [user] = await db.select().from(users).where(eq(users.resetPasswordTokenHash, hashToken(token)));
|
||
return user || undefined;
|
||
}
|
||
|
||
async getUserByEmailAndSlug(email: string, slug: string): Promise<UserWithOrganization | undefined> {
|
||
const [result] = await db
|
||
.select()
|
||
.from(users)
|
||
.innerJoin(organizations, eq(users.organizationId, organizations.id))
|
||
.where(and(ilike(users.email, email), eq(organizations.slug, slug)));
|
||
|
||
if (!result) return undefined;
|
||
|
||
return {
|
||
...result.users,
|
||
organization: result.organizations,
|
||
};
|
||
}
|
||
|
||
// Массовая выдача пользователей клиенту — только безопасные колонки (без хэша пароля и токенов).
|
||
// options.since — дельта-синк: фильтр по updated_at на уровне SQL
|
||
// (NULL updated_at включаем для паритета со старым JS-фильтром).
|
||
async getUsersByOrganization(organizationId: number, options?: { since?: Date }): Promise<SafeUser[]> {
|
||
const conditions = [eq(users.organizationId, organizationId)];
|
||
if (options?.since) {
|
||
conditions.push(or(
|
||
gte(users.updatedAt, options.since),
|
||
isNull(users.updatedAt)
|
||
)!);
|
||
}
|
||
return await db
|
||
.select(safeUserColumns)
|
||
.from(users)
|
||
.where(and(...conditions))
|
||
.orderBy(desc(users.createdAt));
|
||
}
|
||
|
||
async listUsers(organizationId: number, options: ListUsersOptions = {}): Promise<ListUsersResult> {
|
||
const conditions: any[] = [eq(users.organizationId, organizationId)];
|
||
|
||
if (options.search) {
|
||
const term = `%${options.search}%`;
|
||
conditions.push(
|
||
or(
|
||
ilike(users.firstName, term),
|
||
ilike(users.lastName, term),
|
||
ilike(users.middleName, term),
|
||
ilike(users.email, term),
|
||
ilike(users.position, term),
|
||
ilike(users.phone, term)
|
||
)
|
||
);
|
||
}
|
||
|
||
if (options.appRole) {
|
||
conditions.push(eq(users.appRole, options.appRole));
|
||
}
|
||
|
||
if (options.isActive !== undefined) {
|
||
conditions.push(eq(users.isActive, options.isActive));
|
||
}
|
||
|
||
const whereClause = and(...conditions);
|
||
|
||
const orderColumn =
|
||
options.sortBy === 'name'
|
||
? users.lastName
|
||
: options.sortBy === 'lastLogin'
|
||
? users.lastLogin
|
||
: users.createdAt;
|
||
const orderDir = options.order === 'asc' ? asc(orderColumn) : desc(orderColumn);
|
||
|
||
const limit = options.limit ?? 50;
|
||
const offset = options.offset ?? 0;
|
||
|
||
const [countResult] = await db
|
||
.select({ total: sql<number>`count(*)::int` })
|
||
.from(users)
|
||
.where(whereClause);
|
||
|
||
let query = db
|
||
.select(safeUserColumns)
|
||
.from(users)
|
||
.where(whereClause)
|
||
.orderBy(orderDir)
|
||
.limit(limit)
|
||
.offset(offset);
|
||
|
||
// Если фильтр по организационной роли — присоединяем roleMembers
|
||
if (options.roleId) {
|
||
query = query.innerJoin(
|
||
roleMembers,
|
||
and(eq(roleMembers.userId, users.id), eq(roleMembers.roleId, options.roleId))
|
||
) as any;
|
||
}
|
||
|
||
// При явном списке колонок drizzle возвращает плоские строки даже с join
|
||
const rows: SafeUser[] = await query;
|
||
return {
|
||
users: rows,
|
||
total: countResult?.total || 0,
|
||
};
|
||
}
|
||
|
||
async getLinkedTasksForUser(
|
||
userId: number,
|
||
organizationId: number
|
||
): Promise<
|
||
Array<{
|
||
task: Task;
|
||
form: { id: number; name: string };
|
||
status: FormStatus | null;
|
||
field: FormField;
|
||
}>
|
||
> {
|
||
// Находим поля типа user с включённой связью к профилю
|
||
const linkedFields = await db
|
||
.select({ field: formFields, formId: forms.id, formName: forms.name })
|
||
.from(formFields)
|
||
.innerJoin(forms, eq(formFields.formId, forms.id))
|
||
.where(
|
||
and(
|
||
eq(forms.organizationId, organizationId),
|
||
eq(formFields.type, 'user'),
|
||
eq(formFields.linkToUserProfile, true)
|
||
)
|
||
);
|
||
|
||
if (linkedFields.length === 0) return [];
|
||
|
||
const fieldIds = linkedFields.map((f) => f.field.id);
|
||
|
||
// Значения полей, указывающие на этого пользователя
|
||
const values = await db
|
||
.select({
|
||
taskId: taskFieldValues.taskId,
|
||
fieldId: taskFieldValues.fieldId,
|
||
value: taskFieldValues.value,
|
||
})
|
||
.from(taskFieldValues)
|
||
.where(inArray(taskFieldValues.fieldId, fieldIds));
|
||
|
||
const matchingTaskIds = values
|
||
.filter((v) => {
|
||
if (v.value == null) return false;
|
||
const raw = typeof v.value === 'string' ? v.value : JSON.stringify(v.value);
|
||
return raw.includes(`"userId":${userId}`) || raw === String(userId) || raw === `"${userId}"`;
|
||
})
|
||
.map((v) => v.taskId);
|
||
|
||
if (matchingTaskIds.length === 0) return [];
|
||
|
||
const uniqueTaskIds = Array.from(new Set(matchingTaskIds));
|
||
|
||
const taskRows = await db
|
||
.select()
|
||
.from(tasks)
|
||
.where(inArray(tasks.id, uniqueTaskIds));
|
||
|
||
const statusIds = Array.from(new Set(taskRows.map((t) => t.currentStatusId).filter((id): id is number => id != null)));
|
||
const statusRows = statusIds.length > 0
|
||
? await db.select().from(formStatuses).where(inArray(formStatuses.id, statusIds))
|
||
: [];
|
||
const statusMap = new Map(statusRows.map((s) => [s.id, s]));
|
||
|
||
const fieldMap = new Map(linkedFields.map((f) => [f.field.id, { field: f.field, formId: f.formId, formName: f.formName }]));
|
||
|
||
return taskRows.map((task) => {
|
||
const fieldId = values.find((v) => v.taskId === task.id && fieldIds.includes(v.fieldId))?.fieldId ?? linkedFields[0].field.id;
|
||
const meta = fieldMap.get(fieldId)!;
|
||
return {
|
||
task,
|
||
form: { id: meta.formId, name: meta.formName },
|
||
status: task.currentStatusId ? statusMap.get(task.currentStatusId) ?? null : null,
|
||
field: meta.field,
|
||
};
|
||
});
|
||
}
|
||
|
||
async createUser(insertUser: InsertUser): Promise<User> {
|
||
let statusId = insertUser.statusId;
|
||
if (!statusId) {
|
||
const defaultStatus = await userStatusesStorage.ensureDefaultStatus(insertUser.organizationId);
|
||
statusId = defaultStatus.id;
|
||
}
|
||
|
||
const [user] = await db
|
||
.insert(users)
|
||
.values({ ...insertUser, statusId })
|
||
.returning();
|
||
return user;
|
||
}
|
||
|
||
async updateUser(id: number, updates: Partial<User>): Promise<User> {
|
||
const [user] = await db
|
||
.update(users)
|
||
.set({ ...updates, updatedAt: new Date() })
|
||
.where(eq(users.id, id))
|
||
.returning();
|
||
// Смена appRole (например, получение tasks.view_all) меняет набор доступных задач
|
||
if ('appRole' in updates && user?.organizationId) {
|
||
invalidateAccessibleTasksForUser(id, user.organizationId);
|
||
}
|
||
return user;
|
||
}
|
||
|
||
async deleteUser(id: number): Promise<void> {
|
||
await db.delete(users).where(eq(users.id, id));
|
||
}
|
||
|
||
// User sessions
|
||
// Шаг 0.6: в БД хранятся только sha256-хэши токенов (refresh_token_hash и т.д.);
|
||
// plain-колонки (refresh_token, parent_refresh_token, replaced_by_token) не пишутся.
|
||
async createUserSession(session: InsertUserSession): Promise<UserSession> {
|
||
const [userSession] = await db
|
||
.insert(userSessions)
|
||
.values({
|
||
...session,
|
||
refreshToken: null,
|
||
parentRefreshToken: null,
|
||
replacedByToken: null,
|
||
})
|
||
.returning();
|
||
return userSession;
|
||
}
|
||
|
||
async getUserSessionByToken(refreshToken: string): Promise<UserSession | undefined> {
|
||
const [session] = await db
|
||
.select()
|
||
.from(userSessions)
|
||
.where(eq(userSessions.refreshTokenHash, hashToken(refreshToken)));
|
||
return session || undefined;
|
||
}
|
||
|
||
async getUserSessionByParentToken(parentRefreshToken: string): Promise<UserSession | undefined> {
|
||
const [session] = await db
|
||
.select()
|
||
.from(userSessions)
|
||
.where(eq(userSessions.parentRefreshTokenHash, hashToken(parentRefreshToken)));
|
||
return session || undefined;
|
||
}
|
||
|
||
async getActiveSessionInFamily(familyId: string): Promise<UserSession | undefined> {
|
||
const [session] = await db
|
||
.select()
|
||
.from(userSessions)
|
||
.where(and(
|
||
eq(userSessions.familyId, familyId),
|
||
eq(userSessions.isReplaced, false),
|
||
eq(userSessions.isRevoked, false),
|
||
gte(userSessions.expiresAt, new Date())
|
||
))
|
||
.orderBy(desc(userSessions.createdAt))
|
||
.limit(1);
|
||
return session || undefined;
|
||
}
|
||
|
||
async markSessionReplaced(sessionId: number, replacedByToken: string): Promise<void> {
|
||
await db
|
||
.update(userSessions)
|
||
.set({ isReplaced: true, replacedByTokenHash: hashToken(replacedByToken), replacedByToken: null })
|
||
.where(eq(userSessions.id, sessionId));
|
||
}
|
||
|
||
async revokeSession(refreshToken: string): Promise<void> {
|
||
await db
|
||
.update(userSessions)
|
||
.set({ isRevoked: true })
|
||
.where(eq(userSessions.refreshTokenHash, hashToken(refreshToken)));
|
||
}
|
||
|
||
async revokeSessionFamily(familyId: string): Promise<void> {
|
||
await db
|
||
.update(userSessions)
|
||
.set({ isRevoked: true })
|
||
.where(eq(userSessions.familyId, familyId));
|
||
}
|
||
|
||
async revokeAllUserSessions(userId: number): Promise<void> {
|
||
await db
|
||
.update(userSessions)
|
||
.set({ isRevoked: true })
|
||
.where(eq(userSessions.userId, userId));
|
||
}
|
||
|
||
/** Physical deletion kept for cleanup operations. */
|
||
async deleteUserSession(refreshToken: string): Promise<void> {
|
||
await db.delete(userSessions).where(eq(userSessions.refreshTokenHash, hashToken(refreshToken)));
|
||
}
|
||
|
||
async deleteUserSessions(userId: number): Promise<void> {
|
||
await db.delete(userSessions).where(eq(userSessions.userId, userId));
|
||
}
|
||
|
||
// Invitations
|
||
async createInvitation(invitation: InsertInvitation): Promise<Invitation> {
|
||
const [result] = await db
|
||
.insert(invitations)
|
||
.values(invitation)
|
||
.returning();
|
||
return result;
|
||
}
|
||
|
||
async getInvitationByToken(token: string): Promise<Invitation | undefined> {
|
||
const [invitation] = await db
|
||
.select()
|
||
.from(invitations)
|
||
.where(eq(invitations.token, token));
|
||
return invitation || undefined;
|
||
}
|
||
|
||
async getInvitationsByOrganization(organizationId: number): Promise<Invitation[]> {
|
||
return await db
|
||
.select()
|
||
.from(invitations)
|
||
.where(eq(invitations.organizationId, organizationId))
|
||
.orderBy(desc(invitations.createdAt));
|
||
}
|
||
|
||
async markInvitationAsUsed(token: string): Promise<Invitation> {
|
||
const [invitation] = await db
|
||
.update(invitations)
|
||
.set({ usedAt: new Date() })
|
||
.where(eq(invitations.token, token))
|
||
.returning();
|
||
return invitation;
|
||
}
|
||
|
||
async deleteInvitation(id: number, organizationId: number): Promise<void> {
|
||
await db
|
||
.delete(invitations)
|
||
.where(and(eq(invitations.id, id), eq(invitations.organizationId, organizationId)));
|
||
}
|
||
|
||
// User Custom Fields
|
||
async getUserCustomFields(organizationId: number): Promise<UserCustomField[]> {
|
||
return await db
|
||
.select()
|
||
.from(userCustomFields)
|
||
.where(eq(userCustomFields.organizationId, organizationId))
|
||
.orderBy(asc(userCustomFields.position), asc(userCustomFields.id));
|
||
}
|
||
|
||
async getUserCustomField(id: number, organizationId: number): Promise<UserCustomField | undefined> {
|
||
const [field] = await db
|
||
.select()
|
||
.from(userCustomFields)
|
||
.where(and(eq(userCustomFields.id, id), eq(userCustomFields.organizationId, organizationId)));
|
||
return field || undefined;
|
||
}
|
||
|
||
async createUserCustomField(organizationId: number, field: CreateUserCustomField): Promise<UserCustomField> {
|
||
const maxPositionResult = await db
|
||
.select({ maxPos: sql<number>`COALESCE(MAX(${userCustomFields.position}), 0)` })
|
||
.from(userCustomFields)
|
||
.where(eq(userCustomFields.organizationId, organizationId));
|
||
|
||
const nextPosition = (maxPositionResult[0]?.maxPos || 0) + 1;
|
||
|
||
const [created] = await db
|
||
.insert(userCustomFields)
|
||
.values({
|
||
organizationId,
|
||
name: field.name,
|
||
code: field.code,
|
||
type: field.type,
|
||
options: field.options || null,
|
||
isRequired: field.isRequired || false,
|
||
position: nextPosition,
|
||
})
|
||
.returning();
|
||
return created;
|
||
}
|
||
|
||
async updateUserCustomField(id: number, organizationId: number, updates: UpdateUserCustomField): Promise<UserCustomField> {
|
||
const [updated] = await db
|
||
.update(userCustomFields)
|
||
.set({
|
||
...updates,
|
||
updatedAt: new Date(),
|
||
})
|
||
.where(and(eq(userCustomFields.id, id), eq(userCustomFields.organizationId, organizationId)))
|
||
.returning();
|
||
return updated;
|
||
}
|
||
|
||
async deleteUserCustomField(id: number, organizationId: number): Promise<void> {
|
||
await db
|
||
.delete(userCustomFields)
|
||
.where(and(eq(userCustomFields.id, id), eq(userCustomFields.organizationId, organizationId)));
|
||
}
|
||
|
||
async reorderUserCustomFields(organizationId: number, fieldIds: number[]): Promise<void> {
|
||
await db.transaction(async (tx) => {
|
||
for (let i = 0; i < fieldIds.length; i++) {
|
||
await tx
|
||
.update(userCustomFields)
|
||
.set({ position: i })
|
||
.where(and(eq(userCustomFields.id, fieldIds[i]), eq(userCustomFields.organizationId, organizationId)));
|
||
}
|
||
});
|
||
}
|
||
|
||
// User Custom Values
|
||
async getUserCustomValues(userId: number, organizationId: number): Promise<UserCustomValue[]> {
|
||
// Only return values for fields that belong to this organization
|
||
return await db
|
||
.select({
|
||
id: userCustomValues.id,
|
||
userId: userCustomValues.userId,
|
||
fieldId: userCustomValues.fieldId,
|
||
value: userCustomValues.value,
|
||
createdAt: userCustomValues.createdAt,
|
||
updatedAt: userCustomValues.updatedAt,
|
||
})
|
||
.from(userCustomValues)
|
||
.innerJoin(userCustomFields, eq(userCustomValues.fieldId, userCustomFields.id))
|
||
.where(and(
|
||
eq(userCustomValues.userId, userId),
|
||
eq(userCustomFields.organizationId, organizationId)
|
||
));
|
||
}
|
||
|
||
async setUserCustomValue(userId: number, fieldId: number, value: string | null, organizationId: number): Promise<UserCustomValue> {
|
||
// Verify field belongs to organization
|
||
const field = await this.getUserCustomField(fieldId, organizationId);
|
||
if (!field) {
|
||
throw new Error('Поле не найдено или не принадлежит организации');
|
||
}
|
||
|
||
const [existing] = await db
|
||
.select()
|
||
.from(userCustomValues)
|
||
.where(and(eq(userCustomValues.userId, userId), eq(userCustomValues.fieldId, fieldId)));
|
||
|
||
if (existing) {
|
||
const [updated] = await db
|
||
.update(userCustomValues)
|
||
.set({ value, updatedAt: new Date() })
|
||
.where(and(eq(userCustomValues.userId, userId), eq(userCustomValues.fieldId, fieldId)))
|
||
.returning();
|
||
return updated;
|
||
} else {
|
||
const [created] = await db
|
||
.insert(userCustomValues)
|
||
.values({ userId, fieldId, value })
|
||
.returning();
|
||
return created;
|
||
}
|
||
}
|
||
|
||
async setUserCustomValues(userId: number, values: Array<{ fieldId: number; value: string | null }>, organizationId: number): Promise<void> {
|
||
await db.transaction(async (tx) => {
|
||
for (const { fieldId, value } of values) {
|
||
const [field] = await tx
|
||
.select()
|
||
.from(userCustomFields)
|
||
.where(and(eq(userCustomFields.id, fieldId), eq(userCustomFields.organizationId, organizationId)));
|
||
if (!field) throw new Error('Поле не найдено или не принадлежит организации');
|
||
|
||
const [existing] = await tx
|
||
.select()
|
||
.from(userCustomValues)
|
||
.where(and(eq(userCustomValues.userId, userId), eq(userCustomValues.fieldId, fieldId)));
|
||
|
||
if (existing) {
|
||
await tx
|
||
.update(userCustomValues)
|
||
.set({ value, updatedAt: new Date() })
|
||
.where(and(eq(userCustomValues.userId, userId), eq(userCustomValues.fieldId, fieldId)));
|
||
} else {
|
||
await tx
|
||
.insert(userCustomValues)
|
||
.values({ userId, fieldId, value });
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// User offline subscriptions
|
||
async getUserOfflineSubscriptions(userId: number, organizationId: number): Promise<UserOfflineSubscription[]> {
|
||
return await db
|
||
.select()
|
||
.from(userOfflineSubscriptions)
|
||
.where(and(
|
||
eq(userOfflineSubscriptions.userId, userId),
|
||
eq(userOfflineSubscriptions.organizationId, organizationId)
|
||
))
|
||
.orderBy(desc(userOfflineSubscriptions.createdAt));
|
||
}
|
||
|
||
async getUserOfflineSubscription(userId: number, formId: number): Promise<UserOfflineSubscription | undefined> {
|
||
const [sub] = await db
|
||
.select()
|
||
.from(userOfflineSubscriptions)
|
||
.where(and(
|
||
eq(userOfflineSubscriptions.userId, userId),
|
||
eq(userOfflineSubscriptions.formId, formId)
|
||
));
|
||
return sub ?? undefined;
|
||
}
|
||
|
||
async upsertUserOfflineSubscription(subscription: InsertUserOfflineSubscription): Promise<UserOfflineSubscription> {
|
||
const { userId, formId } = subscription;
|
||
const existing = await this.getUserOfflineSubscription(userId, formId);
|
||
if (existing) {
|
||
const [updated] = await db
|
||
.update(userOfflineSubscriptions)
|
||
.set({ ...subscription, updatedAt: new Date() })
|
||
.where(eq(userOfflineSubscriptions.id, existing.id))
|
||
.returning();
|
||
return updated;
|
||
}
|
||
const [created] = await db
|
||
.insert(userOfflineSubscriptions)
|
||
.values({ ...subscription, createdAt: new Date(), updatedAt: new Date() })
|
||
.returning();
|
||
return created;
|
||
}
|
||
|
||
async deleteUserOfflineSubscription(userId: number, formId: number): Promise<void> {
|
||
await db
|
||
.delete(userOfflineSubscriptions)
|
||
.where(and(
|
||
eq(userOfflineSubscriptions.userId, userId),
|
||
eq(userOfflineSubscriptions.formId, formId)
|
||
));
|
||
}
|
||
|
||
}
|