drizzle.config.js imports from drizzle-kit, so it must be installed in production for db:push to work on Coolify. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
CRM Server
Node.js/Express backend for a full-featured CRM system with company/project management, time tracking, email integration (JMAP), internal messaging, calendar events, push notifications, and an AI courses module.
Tech Stack
- Runtime: Node.js with ES modules
- Framework: Express.js
- Database: PostgreSQL via Drizzle ORM
- Auth: JWT (access + refresh tokens), bcrypt passwords
- Email: JMAP protocol
- Files: Multer (disk + memory storage)
- Validation: Zod schemas
- Notifications: Web Push (VAPID)
- Exports: ExcelJS for XLSX timesheets
- Cron: node-cron for scheduled tasks
- Logging: Winston
Project Structure
src/
config/ # Database, env, upload configs
controllers/ # HTTP request handlers
cron/ # Scheduled jobs
db/ # Schema definitions, migrations, seeds
middlewares/ # Auth, security, validation middleware
routes/ # Express route definitions
services/ # Business logic layer
utils/ # Shared utilities
validators/ # Zod validation schemas
Config (src/config/)
| File | Exports |
|---|---|
database.js |
db (Drizzle instance), pool (pg connection pool) |
env.js |
env object — validated env vars (DATABASE_URL, JWT_SECRET, JWT_REFRESH_SECRET, JMAP_URL, ENCRYPTION_KEY, VAPID keys, PORT) |
upload.js |
createUpload() factory for multer configs, ALLOWED_FILE_TYPES constant. Pre-configured exports: upload, timesheetUpload, companyDocumentUpload, projectDocumentUpload, serviceDocumentUpload, aiKurzyUpload |
Middlewares (src/middlewares/)
Auth
| Function | Description |
|---|---|
authenticate |
Verify JWT from cookie or Authorization header, attach user to req |
optionalAuthenticate |
Same but doesn't fail if no token present |
requireAdmin |
Require admin role |
requireMember |
Require member role (or admin) |
checkResourceAccess(type) |
Check user has access to company/project/todo |
getAccessibleResourceIds(type, userId) |
Get IDs of resources a user can access |
Security
| Function | Description |
|---|---|
apiLimiter |
Rate limit: 100 req/15min |
authLimiter |
Rate limit: 5 req/15min (login) |
requireAccountId |
Validate accountId query param |
sanitizeInput(input) |
Strip XSS from user input |
Global
| Function | Description |
|---|---|
validateBody(schema) |
Validate req.body against Zod schema |
errorHandler |
Global error handler, formats errors consistently |
notFound |
404 handler for unknown routes |
Utils (src/utils/)
| File | Exports | Description |
|---|---|---|
jwt.js |
generateAccessToken, generateRefreshToken, generateTokenPair, verifyAccessToken, verifyRefreshToken |
JWT token operations (1h access, 7d refresh) |
password.js |
hashPassword, comparePassword, generateTempPassword |
bcrypt hashing + temp password generation |
logger.js |
logger |
Winston logger (file + console) |
errors.js |
AppError, NotFoundError, ValidationError, AuthenticationError, ForbiddenError, ConflictError |
Custom error classes with HTTP status codes |
emailAccountHelper.js |
encryptPassword, decryptPassword |
AES-256 encryption for stored email passwords |
Validators (src/validators/)
| File | Schemas |
|---|---|
auth.validators.js |
loginSchema, setPasswordSchema, linkEmailSchema, createUserSchema |
crm.validators.js |
createCompanySchema, updateCompanySchema, createProjectSchema, updateProjectSchema, createTodoSchema, updateTodoSchema, createNoteSchema, updateNoteSchema, createReminderSchema, updateReminderSchema, startTimeEntrySchema, stopTimeEntrySchema, updateTimeEntrySchema, createContactSchema |
email-account.validators.js |
createEmailAccountSchema, updatePasswordSchema, toggleStatusSchema, signatureSchema, toggleSchema |
service.validators.js |
serviceIdSchema, folderIdSchema, folderDocumentIdSchema, createFolderSchema, updateFolderSchema |
ai-kurzy.validators.js |
createKurzSchema, updateKurzSchema, createUcastnikSchema, updateUcastnikSchema, createRegistraciaSchema, updateRegistraciaSchema, registracieQuerySchema, updateFieldSchema, prilohaIdSchema |
Services (src/services/)
auth.service.js
| Function | Description |
|---|---|
loginWithTempPassword(username, password, ip, ua) |
Authenticate with temp or permanent password, return JWT pair |
setNewPassword(userId, newPassword, auditCtx) |
Set permanent password, mark as changed |
linkEmail(userId, email, password, auditCtx) |
Link email account via JMAP validation |
skipEmailSetup(userId) |
Skip email onboarding step |
logout(auditCtx) |
Log logout to audit trail |
getUserById(userId) |
Get user with email accounts |
admin.service.js
| Function | Description |
|---|---|
checkUsernameExists(username) |
Check if username is taken |
createUser(username, firstName, lastName, role, email, password, auditCtx) |
Create user with auto-generated temp password |
getAllUsers() |
List all users (no passwords) |
getUserById(userId) |
Get user with email accounts |
changeUserRole(userId, newRole, auditCtx) |
Change role (admin/member) |
updateUser(userId, data) |
Update user name fields |
resetUserPassword(userId) |
Generate new temp password |
deleteUser(userId, auditCtx) |
Delete user and orphaned email accounts |
company.service.js
| Function | Description |
|---|---|
getAllCompanies(search, userId, role) |
Get companies (members see only assigned) |
getCompanyById(id) |
Get company with creator info |
createCompany(userId, data, auditCtx) |
Create company, auto-assign creator |
updateCompany(id, data, auditCtx) |
Update company |
deleteCompany(id, auditCtx) |
Delete company |
getCompanyWithRelations(id) |
Get with projects, todos, notes, reminders |
getCompanyUsers(id) |
Get assigned users |
assignUserToCompany(companyId, userId, addedBy, role, auditCtx) |
Add user to team |
removeUserFromCompany(companyId, userId, auditCtx) |
Remove from team |
updateUserRoleOnCompany(companyId, userId, role) |
Update team role |
company-email.service.js
| Function | Description |
|---|---|
getCompanyEmailThreads(companyId, userId) |
Get email threads grouped by contact |
getCompanyUnreadCounts(userId) |
Unread counts per company |
company-document.service.js
| Function | Description |
|---|---|
getDocumentsByCompanyId(id) |
List company documents |
uploadDocument({ companyId, userId, file, description }) |
Upload document |
getDocumentForDownload(companyId, docId) |
Get file path for download |
deleteDocument(companyId, docId) |
Delete document and file |
company-reminder.service.js
| Function | Description |
|---|---|
getRemindersByCompanyId(id) |
Get reminders for company |
createReminder(companyId, data, auditCtx) |
Create reminder |
updateReminder(companyId, id, data, auditCtx) |
Update reminder |
deleteReminder(companyId, id, auditCtx) |
Delete reminder |
getReminderSummary(userId, role) |
Checked/unchecked stats |
getReminderCountsByCompany(userId, role) |
Counts grouped by company |
getUpcomingReminders(userId, role) |
Upcoming with due dates |
project.service.js
| Function | Description |
|---|---|
getAllProjects(search, companyId, userId, role) |
List with filters (members see assigned only) |
getProjectById(id) |
Get with creator info |
createProject(userId, data, auditCtx) |
Create and auto-assign creator |
updateProject(id, data, auditCtx) |
Update project |
deleteProject(id, auditCtx) |
Delete project |
getProjectWithRelations(id) |
Get with todos, notes, timesheets, team |
getProjectUsers(id) |
Get team members |
assignUserToProject(projectId, userId, addedBy, role, auditCtx) |
Add to team |
removeUserFromProject(projectId, userId, auditCtx) |
Remove from team |
updateUserRoleOnProject(projectId, userId, role) |
Update role |
project-document.service.js
Same pattern as company-document: getDocumentsByProjectId, uploadDocument, getDocumentForDownload, deleteDocument.
todo.service.js
| Function | Description |
|---|---|
getAllTodos(filters, userId, role) |
Get with filters (project, company, status, priority, assignedTo) |
getTodoById(id) |
Get todo |
getTodoWithRelations(id) |
Get with project, company, users, notes |
createTodo(userId, data, auditCtx) |
Create and assign users (triggers push) |
updateTodo(id, data, userId, auditCtx) |
Update and manage assignments |
deleteTodo(id, auditCtx) |
Delete todo |
getOverdueCount(userId, role) |
Count overdue |
getCompletedByMeCount(userId) |
Count completed by user |
getTodoCounts(userId, role) |
Combined counts for sidebar badges |
markCompletedAsNotified(userId) |
Mark completed as notified |
todo-notification.service.js
| Function | Description |
|---|---|
notifyNewTodoAssignment(userIds, title, todoId, excludeId) |
Push notify assigned users |
notifyUpdatedTodoAssignment(userIds, title, todoId, excludeId) |
Push notify newly assigned users |
note.service.js
| Function | Description |
|---|---|
getAllNotes(filters) |
Get with filters (company, project, todo, contact) |
getNoteById(id) |
Get note |
getNotesByCompanyId(id) |
Notes for company |
getNotesByProjectId(id) |
Notes for project |
createNote(userId, data, auditCtx) |
Create note |
updateNote(id, data, auditCtx) |
Update note |
deleteNote(id, auditCtx) |
Delete note |
getUpcomingRemindersForUser(userId) |
Upcoming note reminders |
markReminderAsSent(id) |
Mark reminder sent |
searchNotes(term) |
Search with company/project info |
time-tracking.service.js
| Function | Description |
|---|---|
startTimeEntry(userId, data, auditCtx) |
Start timer (auto-stops any running entry) |
stopTimeEntry(entryId, userId, data, auditCtx) |
Stop and calculate duration |
pauseTimeEntry(entryId, userId, auditCtx) |
Pause running entry |
resumeTimeEntry(entryId, userId, auditCtx) |
Resume paused entry |
getRunningTimeEntry(userId) |
Get active entry for user |
getAllRunningTimeEntries() |
All active entries (admin) |
getAllTimeEntries(userId, filters) |
Filtered time entries |
getMonthlyTimeEntries(userId, year, month) |
Entries for month |
generateMonthlyTimesheet(userId, year, month) |
Generate XLSX timesheet |
generateCompanyTimesheet(userId, year, month, companyId) |
XLSX filtered by company |
getTimeEntryById(id) |
Get single entry |
getTimeEntryWithRelations(id) |
Entry with project/todo/company/user |
updateTimeEntry(id, userCtx, data, auditCtx) |
Update entry |
deleteTimeEntry(id, userCtx, auditCtx) |
Delete entry |
getMonthlyStats(userId, year, month) |
Monthly stats |
timesheet.service.js
| Function | Description |
|---|---|
uploadTimesheet({ userId, year, month, file, auditCtx }) |
Upload timesheet file |
getTimesheetsForUser(userId, filters) |
User's timesheets |
getAllTimesheets(filters) |
All timesheets (admin) |
getDownloadInfo(id, userCtx) |
File path with access check |
deleteTimesheet(id, userCtx, auditCtx) |
Delete timesheet and file |
contact.service.js
| Function | Description |
|---|---|
getContactsForEmailAccount(accountId) |
Get contacts for email account |
addContact(accountId, jmapConfig, email, name, notes, userId) |
Add contact and sync JMAP emails |
removeContact(contactId, accountId) |
Remove contact |
updateContact(contactId, accountId, data) |
Update contact |
linkCompanyToContact(contactId, accountId, companyId, auditCtx) |
Link company |
unlinkCompanyFromContact(contactId, accountId) |
Unlink company |
createCompanyFromContact(contactId, accountId, userId, data, auditCtx) |
Create company and link |
personal-contact.service.js
| Function | Description |
|---|---|
listPersonalContacts(userId) |
List user's personal contacts |
createPersonalContact(userId, data) |
Create contact |
updatePersonalContact(id, userId, data) |
Update contact |
deletePersonalContact(id, userId) |
Delete contact |
getContactsByCompanyId(companyId) |
Contacts by company |
crm-email.service.js
| Function | Description |
|---|---|
getEmailsForAccount(accountId) |
All emails for account |
getEmailThread(accountId, threadId) |
Thread emails |
searchEmails(accountId, term) |
Search by subject/from/to/body |
getUnreadCountSummary(accountIds) |
Unread counts |
markContactEmailsAsRead(contactId, accountId) |
Mark all read for contact |
getContactEmailsWithUnread(accountId, contactId) |
Contact emails with status |
markThreadAsRead(accountId, threadId) |
Mark thread read |
email-account.service.js
| Function | Description |
|---|---|
getUserEmailAccounts(userId) |
Get accounts via many-to-many |
getEmailAccountById(accountId, userId) |
Get with access check |
getEmailAccountWithCredentials(accountId, userId) |
Get with decrypted password |
getPrimaryEmailAccount(userId) |
Get primary account |
createEmailAccount(userId, email, password) |
Create/link via JMAP discovery |
updateEmailAccountPassword(accountId, userId, password) |
Update password |
toggleEmailAccountStatus(accountId, userId, isActive) |
Enable/disable |
setPrimaryEmailAccount(accountId, userId) |
Set as primary |
removeUserFromEmailAccount(accountId, userId) |
Remove user access |
email-signature.service.js
getSignature, upsertSignature, toggleSignature, formatSignatureText, deleteSignature — manage per-user email signatures.
message.service.js
| Function | Description |
|---|---|
getConversations(userId) |
All 1-on-1 conversations with unread counts |
getConversation(userId, partnerId) |
Messages with user, mark as read |
sendMessage(senderId, receiverId, content) |
Send direct message |
deleteConversation(userId, partnerId) |
Soft delete conversation |
getUnreadCount(userId) |
Total unread count |
getChatUsers(userId) |
All users except self |
group.service.js
| Function | Description |
|---|---|
createGroup(name, createdById, memberIds) |
Create group chat |
getUserGroups(userId) |
Groups with unread counts |
getGroupDetails(groupId, userId) |
Group with members |
getGroupMessages(groupId, userId) |
Messages, update last read |
sendGroupMessage(groupId, senderId, content) |
Send to group |
addGroupMember(groupId, userId, requesterId) |
Add member |
removeGroupMember(groupId, userId, requesterId) |
Remove member |
updateGroupName(groupId, name, userId) |
Rename group |
deleteGroup(groupId, userId) |
Delete (creator only) |
push.service.js
| Function | Description |
|---|---|
getVapidPublicKey() |
Get VAPID public key |
saveSubscription(userId, sub) |
Save push subscription |
removeSubscription(userId, endpoint) |
Remove subscription |
removeAllSubscriptions(userId) |
Remove all for user |
hasActiveSubscription(userId) |
Check if subscribed |
sendPushNotification(userId, payload) |
Send push to user |
event.service.js
| Function | Description |
|---|---|
getCalendarData(year, month, userId, isAdmin) |
Calendar events + todos |
getEventById(eventId, userId, isAdmin) |
Event with access check |
createEvent(userId, data) |
Create and assign users |
updateEvent(eventId, data) |
Update and reassign users |
deleteEvent(eventId) |
Delete event |
audit.service.js
Provides logLogin, logLogout, logPasswordChange, logEmailLink, logUserCreation, logRoleChange, logUserDeleted, logCompanyCreated, logCompanyUpdated, logCompanyDeleted, logCompanyUserAssigned, logCompanyUserRemoved, logProjectCreated, logProjectUpdated, logProjectDeleted, logProjectUserAssigned, logProjectUserRemoved, and more. Each function records action, resource, details, IP, and user agent to the auditLogs table.
service.service.js / service-folder.service.js / service-document.service.js
Standard CRUD for services, folders, and folder documents. Same pattern: getAll, getById, create, update, delete.
ai-kurzy/ (barrel exported from ai-kurzy.service.js)
kurzy.service.js — getAllKurzy, getKurzById, createKurz, updateKurz, deleteKurz, getKurzyStats (course CRUD + statistics)
ucastnici.service.js — getAllUcastnici, getUcastnikById, createUcastnik, updateUcastnik, deleteUcastnik (participant CRUD)
registracie.service.js — getAllRegistracie, getRegistraciaById, createRegistracia, updateRegistracia, deleteRegistracia, getCombinedTableData, updateField, getPrilohyByRegistracia, createPriloha, deletePriloha (registrations + combined table view + inline field editing + attachments)
jmap/ (email protocol integration)
| Function | Description |
|---|---|
getJmapConfig(userId) |
Get JMAP config from user's primary email |
getJmapConfigFromAccount(account) |
Build JMAP config from account object |
discoverContactsFromJMAP(config, accountId, search, limit) |
Search and discover contacts |
syncEmailsFromSender(config, accountId, contactId, email, opts) |
Sync emails from sender |
markEmailAsRead(config, userId, jmapId, isRead) |
Mark read/unread on JMAP |
sendEmail(config, userId, accountId, to, subject, body, inReplyTo, threadId) |
Send via JMAP |
searchEmailsJMAP(config, accountId, query, limit, offset) |
Full-text JMAP search |
status.service.js
getServerStatus() — returns uptime, memory usage, CPU, database connection info.
Controllers (src/controllers/)
Controllers are thin HTTP handlers. Each extracts params/body from req, calls the corresponding service, and returns JSON. After the refactoring, audit logging is handled inside services via the auditContext parameter ({ userId, ipAddress, userAgent }).
| Controller | Handles |
|---|---|
auth.controller.js |
Login, logout, refresh token, set password, link email, session |
admin.controller.js |
User CRUD (admin only), server status, trigger notifications |
company.controller.js |
Company CRUD, email threads, unread counts |
company-team.controller.js |
Company user assignments |
company-note.controller.js |
Company notes CRUD |
company-reminder.controller.js |
Company reminders CRUD + summary/upcoming |
company-document.controller.js |
Company document upload/download/delete |
project.controller.js |
Project CRUD, notes, team, documents |
project-document.controller.js |
Project document upload/download/delete |
todo.controller.js |
Todo CRUD, toggle, counts, overdue, my todos |
note.controller.js |
Note CRUD, search, reminders |
time-tracking.controller.js |
Start/stop/pause/resume timer, entries, stats, timesheet generation |
timesheet.controller.js |
Timesheet upload/download/delete |
contact.controller.js |
Email contacts, discover, link/create company |
personal-contact.controller.js |
Personal contacts CRUD |
crm-email.controller.js |
Email list, threads, search, sync, reply, read status |
email-account.controller.js |
Email account CRUD, password, status, primary |
email-signature.controller.js |
Signature CRUD, toggle, formatted output |
message.controller.js |
1-on-1 messaging, conversations, unread count |
group.controller.js |
Group chat CRUD, messages, members |
push.controller.js |
Push subscription, VAPID key, test push |
event.controller.js |
Calendar events CRUD, notifications |
audit.controller.js |
Audit log listing and filters (admin) |
service.controller.js |
Services CRUD |
service-folder.controller.js |
Service folders CRUD |
service-document.controller.js |
Folder documents upload/download/delete |
ai-kurzy.controller.js |
Courses, participants, registrations, combined table, attachments, stats |
Routes (src/routes/)
All routes are prefixed with /api. Authentication is required unless noted.
| Prefix | Methods | Description |
|---|---|---|
/api/auth |
POST login/logout/refresh/set-password/link-email/skip-email, GET session/me | Auth flow |
/api/admin |
GET/POST/PATCH/DELETE users, GET server-status, POST trigger-notifications | Admin only |
/api/companies |
Full CRUD + nested notes, reminders, users, documents, email-threads | Company management |
/api/projects |
Full CRUD + nested notes, users, documents | Project management |
/api/todos |
Full CRUD + /my, /counts, /toggle, /overdue-count | Task management |
/api/notes |
CRUD + /search, /my-reminders | Notes with reminders |
/api/time-tracking |
start/stop/pause/resume, entries, monthly, generate timesheets | Time tracking |
/api/timesheets |
Upload/download/delete, /my, /all | Timesheet files |
/api/contacts |
CRUD + /discover, link/unlink/create company | Email contacts |
/api/personal-contacts |
CRUD + /company/:companyId | Personal contacts |
/api/emails |
List, search, sync, reply, threads, read status | Email operations |
/api/email-accounts |
CRUD + password, status toggle, set-primary | Email accounts |
/api/email-signature |
CRUD + toggle, formatted | Email signatures |
/api/messages |
Conversations, send, unread, users | Direct messages |
/api/groups |
CRUD + messages, members | Group chat |
/api/push |
Subscribe/unsubscribe, VAPID key, status, test | Push notifications |
/api/events |
CRUD + /notify | Calendar events (admin-managed) |
/api/audit |
GET logs, actions, resources | Audit trail (admin) |
/api/services |
CRUD + folders + folder documents | Service catalog |
/api/ai-kurzy |
Courses, participants, registrations, combined table, attachments, stats | AI courses module |
/api/users |
GET all users | Basic user list |
Cron Jobs (src/cron/)
| Job | Schedule | Description |
|---|---|---|
notifyUpcomingEvents |
Every 15 min | Push notifications for events starting within 30 min |
cleanupOldAuditLogs |
Daily 2:00 AM | Delete audit logs older than 90 days |
Manual triggers available via admin endpoints: triggerEventNotifications(), triggerSingleEventNotification(eventId, adminUserId).
Database Schema (src/db/schema.js)
34 tables organized by domain:
Auth & Users: users, emailAccounts, userEmailAccounts, auditLogs
CRM Core: companies, companyUsers, companyReminders, companyDocuments, projects, projectUsers, projectDocuments, todos, todoUsers, notes
Time Tracking: timeEntries, timesheets
Email: contacts, personalContacts, emails, emailSignatures
Messaging: messages, chatGroups, chatGroupMembers, groupMessages
Calendar: events, eventUsers
Push: pushSubscriptions
Services: services, serviceFolders, serviceDocuments
AI Courses: kurzy, ucastnici, registracie, prilohy
Key Enums
role: admin, memberprojectStatus: active, completed, on_hold, cancelledtodoStatus: pending, in_progress, completed, cancelledtodoPriority: low, medium, high, urgentcompanyStatus: registered, lead, customer, inactiveformaKurzu: prezencne, online, hybridnestavRegistracie: potencialny, registrovany, potvrdeny, absolvoval, zrusenytypPrilohy: certifikat, faktura, prihlaska, doklad_o_platbe, ine
Access Control
- Admin — full access to all resources, user management, server status
- Member — sees only companies/projects they are assigned to; todos filtered accordingly
- Resource access checked via
checkResourceAccessmiddleware andgetAccessibleResourceIdshelper - Creator is auto-assigned to companies and projects on creation
Audit Logging
All write operations log to auditLogs via the auditContext pattern. Services receive { userId, ipAddress, userAgent } and call the audit service internally. Tracked: logins, logouts, password changes, CRUD on companies/projects/todos/notes/timesheets, user assignments, admin actions.