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:
@@ -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);
|
||||
});
|
||||
104
src/scripts/fix-wrong-contact-associations.js
Normal file
104
src/scripts/fix-wrong-contact-associations.js
Normal 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);
|
||||
});
|
||||
189
src/scripts/fresh-database.js
Normal file
189
src/scripts/fresh-database.js
Normal 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
75
src/scripts/seed-admin.js
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user