Skip to content
Merged
7 changes: 6 additions & 1 deletion apps/api/src/db/models/beta-applicant.model.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import mongoose, { Schema, Document } from 'mongoose';

export interface BetaApplicantDocument extends Document {
export interface BetaApplicantDocument extends Document<any> {
_id: string;
email: string;
platform: 'win' | 'mac-arm' | 'mac-intel' | 'linux';
motivation: string;
Expand All @@ -9,6 +10,10 @@ export interface BetaApplicantDocument extends Document {

const BetaApplicantSchema = new Schema<BetaApplicantDocument>(
{
_id: {
type: String,
default: () => new mongoose.Types.ObjectId().toString()
},
email: {
type: String,
required: true,
Expand Down
7 changes: 6 additions & 1 deletion apps/api/src/db/models/user.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import {
} from '../plugins/index.js';

export interface UserDocument
extends mongoose.Document, SoftDeleteDocument, AuditDocument, TimestampDocument {
extends mongoose.Document<any>, SoftDeleteDocument, AuditDocument, TimestampDocument {
_id: string;
email: string;
passwordHash?: string | null;
name: string;
Expand All @@ -25,6 +26,10 @@ export interface UserDocument

const userSchema = new Schema<UserDocument>(
{
_id: {
type: String,
default: () => new mongoose.Types.ObjectId().toString()
},
email: {
type: String,
required: true,
Expand Down
7 changes: 6 additions & 1 deletion apps/api/src/db/models/workspace.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ export interface WorkspaceMember {
}

export interface WorkspaceDocument
extends mongoose.Document, SoftDeleteDocument, AuditDocument, TimestampDocument {
extends mongoose.Document<any>, SoftDeleteDocument, AuditDocument, TimestampDocument {
_id: string;
name: string;
slug: string;
ownerId: string;
Expand All @@ -39,6 +40,10 @@ export interface WorkspaceDocument

const workspaceSchema = new Schema<WorkspaceDocument>(
{
_id: {
type: String,
default: () => new mongoose.Types.ObjectId().toString()
},
name: {
type: String,
required: true,
Expand Down
6 changes: 5 additions & 1 deletion apps/api/src/db/plugins/workspace.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import type { Schema } from 'mongoose';
import mongoose, { type Schema } from 'mongoose';

export interface WorkspaceScopedDocument {
workspaceId: string;
}

export function workspacePlugin(schema: Schema) {
schema.add({
_id: {
type: String,
default: () => new mongoose.Types.ObjectId().toString()
},
workspaceId: {
type: String,
required: true,
Expand Down
7 changes: 6 additions & 1 deletion apps/api/src/repositories/base/base.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ import {

type FilterQuery<T> = any;

export class BaseRepository<T extends Document> {
export class BaseRepository<T extends Document<any>> {
constructor(
protected model: Model<T>,
protected workspaceId?: string
) {}



/**
* Translates mongoose exceptions to domain errors.
*/
Expand Down Expand Up @@ -86,6 +88,9 @@ export class BaseRepository<T extends Document> {
public async create(data: Partial<T> | any, session?: ClientSession): Promise<T> {
try {
const payload = this.workspaceId ? { ...data, workspaceId: this.workspaceId } : data;
if (payload.id && !payload._id) {
payload._id = payload.id;
}
const doc = new this.model(payload);

const saveOptions = session ? { session } : {};
Expand Down
5 changes: 4 additions & 1 deletion apps/api/src/services/outreach/outreach.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ export class OutreachService {
* Encrypts SMTP App Password and saves the email account to the workspace database.
*/
public async createEmailAccount(data: any): Promise<EmailAccountDocument> {
const encrypted = encrypt(data.password);
const rawPassword = data.password || data.smtpPassword || data.imapPassword || '';
const encrypted = encrypt(rawPassword);

if (data.isDefault) {
await EmailAccountModel.updateMany({ workspaceId: this.workspaceId } as any, {
Expand All @@ -25,6 +26,7 @@ export class OutreachService {
}

const account = new EmailAccountModel({
_id: data.id || data._id || undefined,
workspaceId: this.workspaceId as any,
name: data.name,
email: data.email,
Expand Down Expand Up @@ -93,6 +95,7 @@ export class OutreachService {
const variables = Array.from(new Set([...bodyVars, ...subjectVars]));

const template = new EmailTemplateModel({
_id: data.id || data._id || undefined,
workspaceId: new mongoose.Types.ObjectId(this.workspaceId),
name: data.name,
subject: data.subject,
Expand Down
51 changes: 42 additions & 9 deletions apps/desktop/src/main/database/repositories/local-crm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,21 @@ export const LocalCRMRepository = {
const operation = existing ? 'UPDATE' : 'CREATE';

// 2. Perform write on target table
db.prepare(query).run(...params);
if (existing) {
const updateColumns = columns.filter((c) => c !== 'id');
const setClause = updateColumns.map((c) => `${c} = ?`).join(', ');
const updateQuery = `UPDATE ${tableName} SET ${setClause} WHERE id = ?`;
const updateParams = updateColumns.map((col) => {
const val = record[col];
if (val instanceof Date) return val.toISOString();
if (typeof val === 'object' && val !== null) return JSON.stringify(val);
return val;
});
updateParams.push(record.id);
db.prepare(updateQuery).run(...updateParams);
} else {
db.prepare(query).run(...params);
}

// 3. Queue offline mutation task if this is a syncable crm table
const syncableTables = [
Expand Down Expand Up @@ -218,7 +232,12 @@ export const LocalCRMRepository = {
const placeholders = columns.map(() => '?').join(', ');
const query = `INSERT OR REPLACE INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders})`;

const updateColumns = columns.filter((c) => c !== 'id');
const setClause = updateColumns.map((c) => `${c} = ?`).join(', ');
const updateQuery = `UPDATE ${tableName} SET ${setClause} WHERE id = ?`;

const statement = db.prepare(query);
const updateStatement = db.prepare(updateQuery);
const checkStmt = db.prepare(`SELECT * FROM ${tableName} WHERE id = ?`);
const insertSyncQueue = db.prepare(`
INSERT INTO sync_queue (id, workspaceId, entityType, entityId, operation, payload, version, retryCount, lastError, createdAt, updatedAt)
Expand All @@ -237,17 +256,31 @@ export const LocalCRMRepository = {

const transaction = db.transaction((list: any[]) => {
for (const item of list) {
const params = columns.map((col) => {
const val = item[col];
if (val instanceof Date) return val.toISOString();
if (typeof val === 'object' && val !== null) return JSON.stringify(val);
return val;
});

const existing = checkStmt.get(item.id) as any;
const operation = existing ? 'UPDATE' : 'CREATE';

statement.run(...params);
if (existing) {
// Prevent remote sync from overwriting local changes still queued to upload
if (existing.syncStatus === 'pending') {
continue;
}
const updateParams = updateColumns.map((col) => {
const val = item[col];
if (val instanceof Date) return val.toISOString();
if (typeof val === 'object' && val !== null) return JSON.stringify(val);
return val;
});
updateParams.push(item.id);
updateStatement.run(...updateParams);
} else {
const params = columns.map((col) => {
const val = item[col];
if (val instanceof Date) return val.toISOString();
if (typeof val === 'object' && val !== null) return JSON.stringify(val);
return val;
});
statement.run(...params);
}

if (!skipQueue && syncableTables.includes(tableName)) {
insertSyncQueue.run(
Expand Down
22 changes: 17 additions & 5 deletions apps/desktop/src/main/services/sync-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,11 +128,18 @@ export class SyncEngine {
try {
const campaigns = await this.sdk.campaigns.list();
if (campaigns && campaigns.length) {
const records = campaigns.map((c: any) => ({
...c,
workspaceId: this.workspaceId,
syncStatus: 'synced'
}));
const records = campaigns.map((c: any) => {
let status = c.status;
if (status) {
status = status.charAt(0).toUpperCase() + status.slice(1).toLowerCase();
}
return {
...c,
status,
workspaceId: this.workspaceId,
syncStatus: 'synced'
};
});
await LocalCRMRepository.saveMany('campaigns', records, true); // skipQueue = true
}
} catch (e) {
Expand Down Expand Up @@ -328,6 +335,11 @@ export class SyncEngine {
try {
const payload = JSON.parse(item.payload || '{}');

// Map local campaign status casing to server uppercase requirements
if (item.entityType === 'campaigns' && payload.status) {
payload.status = payload.status.toUpperCase();
}

// Resolve SDK module dynamically
const clientModule = this.resolveSdkModule(item.entityType);
if (!clientModule) {
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/renderer/components/auth/animated-bg.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,10 +171,10 @@ export function AnimatedBackground() {
className="pointer-events-none absolute inset-0 overflow-hidden bg-[#000000]"
>
{/* 1. Noise texture — filmic grain for surface depth */}
<div className="absolute inset-0 auth-noise-texture opacity-10 z-0" />
<div className="absolute inset-0 auth-noise-texture opacity-20 z-0" />

{/* 2. Scanlines — subtle CRT depth effect */}
<div className="absolute inset-0 auth-scanlines-overlay opacity-[0.18] z-0" />
<div className="absolute inset-0 auth-scanlines-overlay opacity-[0.18] z-10" />

{/* 3. Interactive constellation canvas */}
<canvas
Expand Down
8 changes: 4 additions & 4 deletions apps/desktop/src/renderer/layouts/AuthLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,14 @@ export function AuthLayout() {
return (
<div className="grid h-screen w-screen grid-cols-1 bg-background md:grid-cols-2 select-none">
{/* Form column */}
<div className="relative flex flex-col overflow-hidden z-10">
<div className="relative flex flex-col overflow-hidden">
<BrandPanel className="p-8" />
<div className="flex flex-1 items-center justify-center px-8 pb-8 relative">
{/* Subtle background pulsing glows behind the glassmorphic card */}
<div className="absolute top-[10%] left-[10%] w-[180px] h-[180px] bg-primary/10 rounded-full filter blur-[60px] pointer-events-none" />
<div className="absolute bottom-[10%] right-[10%] w-[160px] h-[160px] bg-info/8 rounded-full filter blur-[50px] pointer-events-none" />
<div className="absolute top-[10%] left-[10%] w-[180px] h-[180px] bg-primary/30 rounded-full filter blur-[60px] pointer-events-none z-10" />
<div className="absolute bottom-[10%] right-[10%] w-[160px] h-[160px] bg-info/20 rounded-full filter blur-[50px] pointer-events-none z-10" />

<div className="w-full max-w-md bg-card/45 backdrop-blur-xl border border-border-subtle/60 rounded-none p-10 shadow-[0_8px_32px_0_rgba(0,0,0,0.37)] relative overflow-hidden">
<div className="w-full max-w-md bg-card/45 backdrop-blur-xl border border-border-subtle/60 rounded-none p-10 shadow-[0_8px_32px_0_rgba(0,0,0,0.37)] relative overflow-hidden z-50">
{/* Top-left decorative dots */}
<svg
className="absolute top-3 left-3 opacity-25 text-primary w-10 h-10"
Expand Down
9 changes: 2 additions & 7 deletions packages/schema/src/fields/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,12 @@ const emptyToNull = (val: unknown) => {
return val;
};

export const objectIdField = z.string().regex(/^[0-9a-fA-F]{24}$/, {
message: 'Invalid ObjectId format'
});
export const objectIdField = z.string().min(1, 'ID is required');

// Nullable variant that accepts empty strings (coerces them to null)
export const objectIdFieldNullable = z.preprocess(
emptyToNull,
z
.string()
.regex(/^[0-9a-fA-F]{24}$/, { message: 'Invalid ObjectId format' })
.nullable()
z.string().nullable()
);

export const emailField = z.string().email({ message: 'Invalid email address' });
Expand Down
Loading