Add Timesheets API with file upload and role-based access

Backend Features:
- Timesheets database table (id, userId, fileName, filePath, fileType, fileSize, year, month, timestamps)
- File upload with multer (memory storage, 10MB limit, PDF/Excel validation)
- Structured file storage: uploads/timesheets/{userId}/{year}/{month}/
- RESTful API endpoints:
  * POST /api/timesheets/upload - Upload timesheet
  * GET /api/timesheets/my - Get user's timesheets (with filters)
  * GET /api/timesheets/all - Get all timesheets (admin only)
  * GET /api/timesheets/:id/download - Download file
  * DELETE /api/timesheets/:id - Delete timesheet
- Role-based permissions: users access own files, admins access all
- Proper error handling and file cleanup on errors
- Database migration for timesheets table

Technical:
- Uses req.user.role for permission checks
- Automatic directory creation for user/year/month structure
- Blob URL cleanup and proper file handling
- Integration with existing auth middleware

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
richardtekula
2025-11-21 08:35:30 +01:00
parent 05be898259
commit bb851639b8
27 changed files with 2847 additions and 532 deletions

View File

@@ -1,90 +0,0 @@
import { db } from '../config/database.js';
import { contacts, emails } from '../db/schema.js';
import { eq, and, sql } from 'drizzle-orm';
import { logger } from '../utils/logger.js';
/**
* Fix duplicate contacts by merging them
* - Finds contacts with the same email address
* - Keeps the newest contact
* - Updates all emails to use the newest contact ID
* - Deletes old duplicate contacts
*/
async function fixDuplicateContacts() {
try {
logger.info('🔍 Finding duplicate contacts...');
// Find duplicate contacts (same userId + email)
const duplicates = await db
.select({
userId: contacts.userId,
email: contacts.email,
count: sql`count(*)::int`,
ids: sql`array_agg(${contacts.id} ORDER BY ${contacts.createdAt} DESC)`,
})
.from(contacts)
.groupBy(contacts.userId, contacts.email)
.having(sql`count(*) > 1`);
if (duplicates.length === 0) {
logger.success('✅ No duplicate contacts found!');
return;
}
logger.info(`Found ${duplicates.length} sets of duplicate contacts`);
let totalFixed = 0;
let totalDeleted = 0;
for (const dup of duplicates) {
const contactIds = dup.ids;
const newestContactId = contactIds[0]; // First one (ordered by createdAt DESC)
const oldContactIds = contactIds.slice(1); // Rest are duplicates
logger.info(`\n📧 Fixing duplicates for ${dup.email}:`);
logger.info(` - Keeping contact: ${newestContactId}`);
logger.info(` - Merging ${oldContactIds.length} duplicate(s): ${oldContactIds.join(', ')}`);
// Update all emails from old contacts to use the newest contact ID
for (const oldContactId of oldContactIds) {
const updateResult = await db
.update(emails)
.set({ contactId: newestContactId, updatedAt: new Date() })
.where(eq(emails.contactId, oldContactId))
.returning();
if (updateResult.length > 0) {
logger.success(` ✅ Updated ${updateResult.length} emails from ${oldContactId}${newestContactId}`);
totalFixed += updateResult.length;
}
// Delete the old duplicate contact
await db
.delete(contacts)
.where(eq(contacts.id, oldContactId));
logger.success(` 🗑️ Deleted duplicate contact: ${oldContactId}`);
totalDeleted++;
}
}
logger.success(`\n✅ Cleanup complete!`);
logger.success(` - Fixed ${totalFixed} emails`);
logger.success(` - Deleted ${totalDeleted} duplicate contacts`);
} catch (error) {
logger.error('❌ Error fixing duplicate contacts:', error);
throw error;
}
}
// Run the script
fixDuplicateContacts()
.then(() => {
logger.success('🎉 Script completed successfully!');
process.exit(0);
})
.catch((error) => {
logger.error('💥 Script failed:', error);
process.exit(1);
});

View File

@@ -0,0 +1,104 @@
import { db } from '../config/database.js';
import { emails, contacts } from '../db/schema.js';
import { eq, and, or, ne, isNotNull } from 'drizzle-orm';
import { logger } from '../utils/logger.js';
/**
* Fix emails that have wrong contactId
*
* This script finds emails where the contactId doesn't match the actual contact
* based on the from/to fields, and updates them to the correct contactId.
*
* Run with: node src/scripts/fix-wrong-contact-associations.js
*/
async function fixWrongContactAssociations() {
try {
logger.info('🔧 Starting to fix wrong contact associations...');
// Get all contacts grouped by email account
const allContacts = await db
.select()
.from(contacts)
.orderBy(contacts.emailAccountId, contacts.email);
logger.info(`Found ${allContacts.length} contacts to process`);
let totalFixed = 0;
let totalChecked = 0;
// Process each contact
for (const contact of allContacts) {
logger.info(`\n📧 Processing contact: ${contact.email} (${contact.name})`);
// Find emails that belong to this contact but have wrong contactId
// Email belongs to contact if from === contact.email OR to === contact.email
const wrongEmails = await db
.select()
.from(emails)
.where(
and(
eq(emails.emailAccountId, contact.emailAccountId),
ne(emails.contactId, contact.id), // Has different contactId
or(
eq(emails.from, contact.email),
eq(emails.to, contact.email)
)
)
);
totalChecked += wrongEmails.length;
if (wrongEmails.length > 0) {
logger.info(` ⚠️ Found ${wrongEmails.length} emails with wrong contactId`);
for (const email of wrongEmails) {
// Get old contact name for logging
const [oldContact] = await db
.select()
.from(contacts)
.where(eq(contacts.id, email.contactId))
.limit(1);
logger.info(` 📬 Fixing email "${email.subject}"`);
logger.info(` From: ${email.from} → To: ${email.to}`);
logger.info(` Old contact: ${oldContact?.email || 'unknown'} → New contact: ${contact.email}`);
// Update to correct contactId
await db
.update(emails)
.set({ contactId: contact.id })
.where(eq(emails.id, email.id));
totalFixed++;
}
logger.success(` ✅ Fixed ${wrongEmails.length} emails for ${contact.email}`);
} else {
logger.info(` ✅ No wrong associations found for ${contact.email}`);
}
}
logger.success(`\n✅ Fix completed!
- Total contacts checked: ${allContacts.length}
- Total wrong emails found: ${totalChecked}
- Total emails fixed: ${totalFixed}
`);
return { totalContacts: allContacts.length, totalChecked, totalFixed };
} catch (error) {
logger.error('Error fixing contact associations', error);
throw error;
}
}
// Run the script
fixWrongContactAssociations()
.then((result) => {
logger.success('Script finished successfully', result);
process.exit(0);
})
.catch((error) => {
logger.error('Script failed', error);
process.exit(1);
});

View File

@@ -0,0 +1,189 @@
import { db } from '../config/database.js';
import { sql } from 'drizzle-orm';
import { logger } from '../utils/logger.js';
/**
* Fresh database - vymaže všetky tabuľky a vytvorí ich znova
*
* ⚠️ POZOR: Tento script vymaže všetky dáta!
* Použite len na development alebo pri začiatku s novými dátami.
*/
async function freshDatabase() {
try {
logger.warn('\n⚠ POZOR: Tento script vymaže všetky dáta!');
logger.warn(' Čaká sa 5 sekúnd... Stlač Ctrl+C na zrušenie.\n');
// Wait 5 seconds
await new Promise(resolve => setTimeout(resolve, 5000));
logger.info('🔄 Dropping all tables...');
// Drop all tables in correct order (reverse of dependencies)
await db.execute(sql`DROP TABLE IF EXISTS emails CASCADE`);
logger.success(' ✅ Dropped table: emails');
await db.execute(sql`DROP TABLE IF EXISTS contacts CASCADE`);
logger.success(' ✅ Dropped table: contacts');
await db.execute(sql`DROP TABLE IF EXISTS user_email_accounts CASCADE`);
logger.success(' ✅ Dropped table: user_email_accounts');
await db.execute(sql`DROP TABLE IF EXISTS email_accounts CASCADE`);
logger.success(' ✅ Dropped table: email_accounts');
await db.execute(sql`DROP TABLE IF EXISTS audit_logs CASCADE`);
logger.success(' ✅ Dropped table: audit_logs');
await db.execute(sql`DROP TABLE IF EXISTS users CASCADE`);
logger.success(' ✅ Dropped table: users');
await db.execute(sql`DROP TYPE IF EXISTS role CASCADE`);
logger.success(' ✅ Dropped type: role');
logger.info('\n🔨 Creating all tables...');
// Create role enum
await db.execute(sql`CREATE TYPE role AS ENUM ('admin', 'member')`);
logger.success(' ✅ Created type: role');
// Create users table
await db.execute(sql`
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username TEXT NOT NULL UNIQUE,
first_name TEXT,
last_name TEXT,
password TEXT,
temp_password TEXT,
changed_password BOOLEAN NOT NULL DEFAULT false,
role role NOT NULL DEFAULT 'member',
last_login TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
)
`);
logger.success(' ✅ Created table: users');
// Create email_accounts table
await db.execute(sql`
CREATE TABLE email_accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
email_password TEXT NOT NULL,
jmap_account_id TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
)
`);
logger.success(' ✅ Created table: email_accounts');
// Create user_email_accounts junction table
await db.execute(sql`
CREATE TABLE user_email_accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
email_account_id UUID NOT NULL REFERENCES email_accounts(id) ON DELETE CASCADE,
is_primary BOOLEAN NOT NULL DEFAULT false,
added_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE(user_id, email_account_id)
)
`);
logger.success(' ✅ Created table: user_email_accounts');
// Create contacts table
await db.execute(sql`
CREATE TABLE contacts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email_account_id UUID NOT NULL REFERENCES email_accounts(id) ON DELETE CASCADE,
email TEXT NOT NULL,
name TEXT,
notes TEXT,
added_by UUID REFERENCES users(id) ON DELETE SET NULL,
added_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE(email_account_id, email)
)
`);
logger.success(' ✅ Created table: contacts');
// Create emails table
await db.execute(sql`
CREATE TABLE emails (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email_account_id UUID NOT NULL REFERENCES email_accounts(id) ON DELETE CASCADE,
contact_id UUID REFERENCES contacts(id) ON DELETE CASCADE,
jmap_id TEXT UNIQUE,
message_id TEXT UNIQUE,
thread_id TEXT,
in_reply_to TEXT,
"from" TEXT,
"to" TEXT,
subject TEXT,
body TEXT,
is_read BOOLEAN NOT NULL DEFAULT false,
sent_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
date TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
)
`);
logger.success(' ✅ Created table: emails');
// Create audit_logs table
await db.execute(sql`
CREATE TABLE audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
action TEXT NOT NULL,
resource TEXT NOT NULL,
resource_id TEXT,
old_value TEXT,
new_value TEXT,
ip_address TEXT,
user_agent TEXT,
success BOOLEAN NOT NULL DEFAULT true,
error_message TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
)
`);
logger.success(' ✅ Created table: audit_logs');
// Create indexes
logger.info('\n📊 Creating indexes...');
await db.execute(sql`CREATE INDEX idx_user_email_accounts_user ON user_email_accounts(user_id)`);
await db.execute(sql`CREATE INDEX idx_user_email_accounts_account ON user_email_accounts(email_account_id)`);
await db.execute(sql`CREATE INDEX idx_contacts_account ON contacts(email_account_id)`);
await db.execute(sql`CREATE INDEX idx_contacts_email ON contacts(email)`);
await db.execute(sql`CREATE INDEX idx_emails_account ON emails(email_account_id)`);
await db.execute(sql`CREATE INDEX idx_emails_contact ON emails(contact_id)`);
await db.execute(sql`CREATE INDEX idx_emails_thread ON emails(thread_id)`);
await db.execute(sql`CREATE INDEX idx_emails_date ON emails(date DESC)`);
logger.success(' ✅ Created all indexes');
logger.success('\n✅ Fresh database created successfully!');
logger.info('\n📝 Next steps:');
logger.info(' 1. Run seed script to create admin user:');
logger.info(' node src/scripts/seed-admin.js');
logger.info(' 2. Start the server:');
logger.info(' npm run dev');
} catch (error) {
logger.error('❌ Failed to create fresh database:', error);
throw error;
}
}
// Run the script
freshDatabase()
.then(() => {
logger.success('🎉 Script completed!');
process.exit(0);
})
.catch((error) => {
logger.error('💥 Script failed:', error);
process.exit(1);
});

75
src/scripts/seed-admin.js Normal file
View File

@@ -0,0 +1,75 @@
import { db } from '../config/database.js';
import { users } from '../db/schema.js';
import { eq } from 'drizzle-orm';
import { hashPassword } from '../utils/password.js';
import { logger } from '../utils/logger.js';
/**
* Seed admin user
*
* Vytvorí admin používateľa s credentials:
* - username: admin
* - password: admin123
*
* ⚠️ DÔLEŽITÉ: Zmeňte heslo po prvom prihlásení!
*/
async function seedAdmin() {
try {
logger.info('🌱 Creating admin user...');
const username = 'admin';
const password = 'admin123';
const hashedPassword = await hashPassword(password);
// Check if admin already exists
const existingAdmins = await db.select().from(users).where(eq(users.username, username));
if (existingAdmins.length > 0) {
logger.warn('⚠️ Admin user already exists, skipping...');
logger.info('\nAdmin credentials:');
logger.info(' Username: admin');
logger.info(' Password: (unchanged)');
return;
}
// Create admin user
const [admin] = await db
.insert(users)
.values({
username,
password: hashedPassword,
firstName: 'Admin',
lastName: 'User',
role: 'admin',
changedPassword: true, // Admin už má nastavené heslo
})
.returning();
logger.success('✅ Admin user created successfully!');
logger.info('\n📋 Admin credentials:');
logger.info(` Username: ${username}`);
logger.info(` Password: ${password}`);
logger.warn('\n⚠ DÔLEŽITÉ: Zmeňte heslo po prvom prihlásení!');
logger.info('\n📝 Next steps:');
logger.info(' 1. Start the server:');
logger.info(' npm run dev');
logger.info(' 2. Login as admin');
logger.info(' 3. Create users and add email accounts');
} catch (error) {
logger.error('❌ Failed to seed admin user:', error);
throw error;
}
}
// Run the script
seedAdmin()
.then(() => {
logger.success('🎉 Seed completed!');
process.exit(0);
})
.catch((error) => {
logger.error('💥 Seed failed:', error);
process.exit(1);
});