feat: Migrate database from MongoDB to PostgreSQL, updating all services and introducing a new schema.
This commit is contained in:
@@ -1,101 +1,72 @@
|
||||
import { Request, Response } from 'express';
|
||||
import User, { IUser } from '../models/User.js';
|
||||
import OrganizationMember, { OrgRole } from '../models/OrganizationMember.js';
|
||||
import { query } from '../config/database.js';
|
||||
import { snakeToCamel } from '../utils/mapper.js';
|
||||
|
||||
// Define locally to avoid import cycle risks
|
||||
interface IAppUser extends IUser {
|
||||
organizationId?: string;
|
||||
organizationRole?: OrgRole;
|
||||
organizationBanned?: boolean;
|
||||
interface IAppUser_Postgres {
|
||||
id: string;
|
||||
logto_id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: 'guest' | 'user' | 'admin';
|
||||
is_banned: boolean;
|
||||
organization_id?: string;
|
||||
organization_role?: string;
|
||||
organization_banned?: boolean;
|
||||
}
|
||||
|
||||
interface AuthRequest extends Request {
|
||||
appUser?: IAppUser;
|
||||
appUser?: IAppUser_Postgres | any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync user from Auth to MongoDB
|
||||
* Creates user if doesn't exist, updates if exists
|
||||
* Also creates/updates OrganizationMember for the current organization
|
||||
*/
|
||||
export const syncUser = async (req: Request, res: Response) => {
|
||||
console.log('--- syncUser called ---', req.body);
|
||||
try {
|
||||
const { externalId, email, name, organizationId, incomingRole } = req.body;
|
||||
const { logtoId, email, name, organizationId, incomingRole } = req.body;
|
||||
|
||||
if (!externalId || !email || !name) {
|
||||
return res.status(400).json({ error: 'externalId, email e name são obrigatórios.' });
|
||||
if (!logtoId || !email || !name) {
|
||||
return res.status(400).json({ error: 'logtoId, email e name são obrigatórios.' });
|
||||
}
|
||||
|
||||
// 1. Upsert the global User record
|
||||
let user = await User.findOne({ externalId });
|
||||
const userResult = await query(
|
||||
`INSERT INTO users (logto_id, email, name, role, is_banned, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, NOW())
|
||||
ON CONFLICT (logto_id)
|
||||
DO UPDATE SET email = EXCLUDED.email, name = EXCLUDED.name, updated_at = NOW()
|
||||
RETURNING id, logto_id, email, name, role, is_banned`,
|
||||
[logtoId, email, name, 'guest', false]
|
||||
);
|
||||
|
||||
if (user) {
|
||||
user.email = email;
|
||||
user.name = name;
|
||||
await user.save();
|
||||
} else {
|
||||
user = await User.create({
|
||||
externalId,
|
||||
email,
|
||||
name,
|
||||
role: 'guest', // Default global role
|
||||
isBanned: false
|
||||
});
|
||||
}
|
||||
const user = userResult?.rows[0];
|
||||
|
||||
if (organizationId) {
|
||||
let appRole = 'guest';
|
||||
if (incomingRole === 'org:admin') appRole = 'admin';
|
||||
else if (incomingRole === 'org:member') appRole = 'user';
|
||||
|
||||
// Map Auth role to our app role
|
||||
let appRole: OrgRole = 'guest';
|
||||
if (incomingRole === 'org:admin') {
|
||||
appRole = 'admin';
|
||||
} else if (incomingRole === 'org:member') {
|
||||
appRole = 'user';
|
||||
}
|
||||
|
||||
// Use findOneAndUpdate with upsert to handle race conditions atomically
|
||||
// This avoids the need for try/catch on create and handles existing members too
|
||||
const member = await OrganizationMember.findOneAndUpdate(
|
||||
{ userId: externalId, organizationId },
|
||||
{
|
||||
$set: {
|
||||
name,
|
||||
email,
|
||||
// Only update role if it's the first time (creation)
|
||||
// Or we can optionally update it if needed.
|
||||
// For now, let's NOT overwrite role on update to preserve local changes,
|
||||
// UNLESS we want to force sync with Auth.
|
||||
// Let's use $setOnInsert for fields we only want to set on creation.
|
||||
},
|
||||
$setOnInsert: {
|
||||
role: appRole,
|
||||
isBanned: false
|
||||
}
|
||||
},
|
||||
{ upsert: true, new: true, setDefaultsOnInsert: true }
|
||||
const memberResult = await query(
|
||||
`INSERT INTO user_organizations (user_id, organization_id, role, is_banned, updated_at)
|
||||
VALUES ($1, $2, $3, $4, NOW())
|
||||
ON CONFLICT (user_id, organization_id)
|
||||
DO UPDATE SET updated_at = NOW()
|
||||
RETURNING role, is_banned`,
|
||||
[user.id, organizationId, appRole, false]
|
||||
);
|
||||
|
||||
// Return combined info
|
||||
return res.json({
|
||||
...user.toObject(),
|
||||
organizationRole: member.role,
|
||||
organizationBanned: member.isBanned
|
||||
});
|
||||
const member = memberResult?.rows[0];
|
||||
|
||||
return res.json(snakeToCamel({
|
||||
...user,
|
||||
organization_role: member.role,
|
||||
organization_banned: member.is_banned
|
||||
}));
|
||||
}
|
||||
|
||||
res.json(user);
|
||||
res.json(snakeToCamel(user));
|
||||
} catch (error) {
|
||||
console.error('Error syncing user:', error);
|
||||
// Retornar 200 mesmo com erro para não travar o frontend se for algo não crítico,
|
||||
// mas aqui é crítico. Vamos logar melhor.
|
||||
res.status(500).json({ error: 'Erro ao sincronizar usuário: ' + (error instanceof Error ? error.message : String(error)) });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get current user data with organization context
|
||||
*/
|
||||
export const getCurrentUser = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
if (!req.appUser) {
|
||||
@@ -105,53 +76,53 @@ export const getCurrentUser = async (req: AuthRequest, res: Response) => {
|
||||
const organizationId = req.headers['x-organization-id'] as string;
|
||||
|
||||
if (organizationId) {
|
||||
const member = await OrganizationMember.findOne({
|
||||
userId: req.appUser.externalId,
|
||||
organizationId
|
||||
});
|
||||
const memberResult = await query(
|
||||
'SELECT role, is_banned FROM user_organizations WHERE user_id = $1 AND organization_id = $2',
|
||||
[req.appUser.id, organizationId]
|
||||
);
|
||||
|
||||
if (member) {
|
||||
return res.json({
|
||||
...req.appUser.toObject(),
|
||||
if (memberResult?.rows[0]) {
|
||||
const member = memberResult.rows[0];
|
||||
return res.json(snakeToCamel({
|
||||
...req.appUser,
|
||||
role: member.role,
|
||||
isBanned: member.isBanned,
|
||||
organizationId
|
||||
});
|
||||
is_banned: member.is_banned,
|
||||
organization_id: organizationId
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
res.json(req.appUser);
|
||||
res.json(snakeToCamel(req.appUser));
|
||||
} catch (error) {
|
||||
console.error('Error getting current user:', error);
|
||||
res.status(500).json({ error: 'Erro ao buscar usuário.' });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all users for the current organization (admin only)
|
||||
*/
|
||||
export const getAllUsers = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const organizationId = req.headers['x-organization-id'] as string;
|
||||
|
||||
console.log('getAllUsers called with organizationId:', organizationId);
|
||||
|
||||
if (!organizationId) {
|
||||
return res.status(400).json({ error: 'Organização não selecionada.' });
|
||||
}
|
||||
|
||||
const members = await OrganizationMember.find({ organizationId }).sort({ createdAt: -1 });
|
||||
console.log(`Found ${members.length} members for org ${organizationId}:`, members.map(m => ({ name: m.name, email: m.email, externalId: m.userId })));
|
||||
res.json(members);
|
||||
const result = await query(
|
||||
`SELECT u.id, u.email, u.name, uo.role, uo.is_banned, uo.created_at
|
||||
FROM users u
|
||||
JOIN user_organizations uo ON u.id = uo.user_id
|
||||
WHERE uo.organization_id = $1
|
||||
ORDER BY uo.created_at DESC`,
|
||||
[organizationId]
|
||||
);
|
||||
|
||||
res.json((result?.rows || []).map(snakeToCamel));
|
||||
} catch (error) {
|
||||
console.error('Error getting users:', error);
|
||||
res.status(500).json({ error: 'Erro ao buscar usuários.' });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update user role within organization (admin only)
|
||||
*/
|
||||
export const updateUserRole = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
@@ -163,35 +134,25 @@ export const updateUserRole = async (req: AuthRequest, res: Response) => {
|
||||
}
|
||||
|
||||
if (!['guest', 'user', 'admin'].includes(role)) {
|
||||
return res.status(400).json({ error: 'Role inválido. Use: guest, user ou admin.' });
|
||||
return res.status(400).json({ error: 'Role inválido.' });
|
||||
}
|
||||
|
||||
const member = await OrganizationMember.findById(id);
|
||||
if (!member || member.organizationId !== organizationId) {
|
||||
return res.status(404).json({ error: 'Usuário não encontrado nesta organização.' });
|
||||
const result = await query(
|
||||
'UPDATE user_organizations SET role = $1, updated_at = NOW() WHERE user_id = $2 AND organization_id = $3 RETURNING *',
|
||||
[role, id, organizationId]
|
||||
);
|
||||
|
||||
if (!result?.rowCount) {
|
||||
return res.status(404).json({ error: 'Membro não encontrado nesta organização.' });
|
||||
}
|
||||
|
||||
// Prevent removing the last admin
|
||||
if (member.role === 'admin' && role !== 'admin') {
|
||||
const adminCount = await OrganizationMember.countDocuments({ organizationId, role: 'admin' });
|
||||
if (adminCount <= 1) {
|
||||
return res.status(400).json({ error: 'Não é possível remover o último administrador.' });
|
||||
}
|
||||
}
|
||||
|
||||
member.role = role as OrgRole;
|
||||
await member.save();
|
||||
|
||||
res.json(member);
|
||||
res.json(snakeToCamel(result.rows[0]));
|
||||
} catch (error) {
|
||||
console.error('Error toggling ban:', error);
|
||||
res.status(500).json({ error: 'Erro ao alterar status de banimento.' });
|
||||
console.error('Error updating role:', error);
|
||||
res.status(500).json({ error: 'Erro ao alterar role.' });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Ban or unban user within organization (admin only)
|
||||
*/
|
||||
export const toggleBanUser = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
@@ -202,95 +163,55 @@ export const toggleBanUser = async (req: AuthRequest, res: Response) => {
|
||||
return res.status(400).json({ error: 'Organização não selecionada.' });
|
||||
}
|
||||
|
||||
const member = await OrganizationMember.findById(id);
|
||||
if (!member || member.organizationId !== organizationId) {
|
||||
return res.status(404).json({ error: 'Usuário não encontrado nesta organização.' });
|
||||
const result = await query(
|
||||
'UPDATE user_organizations SET is_banned = $1, updated_at = NOW() WHERE user_id = $2 AND organization_id = $3 RETURNING *',
|
||||
[isBanned, id, organizationId]
|
||||
);
|
||||
|
||||
if (!result?.rowCount) {
|
||||
return res.status(404).json({ error: 'Membro não encontrado.' });
|
||||
}
|
||||
|
||||
// Prevent banning yourself
|
||||
if (req.appUser && member.userId === req.appUser.externalId) {
|
||||
return res.status(400).json({ error: 'Você não pode banir a si mesmo.' });
|
||||
}
|
||||
|
||||
// Prevent banning another admin
|
||||
if (member.role === 'admin') {
|
||||
return res.status(400).json({ error: 'Não é possível banir um administrador.' });
|
||||
}
|
||||
|
||||
member.isBanned = isBanned;
|
||||
await member.save();
|
||||
|
||||
res.json(member);
|
||||
res.json(snakeToCamel(result.rows[0]));
|
||||
} catch (error) {
|
||||
console.error('Error toggling ban:', error);
|
||||
res.status(500).json({ error: 'Erro ao alterar status de banimento.' });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update current user's lastSeenAt timestamp
|
||||
*/
|
||||
export const heartbeat = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
if (!req.appUser) {
|
||||
return res.status(401).json({ error: 'Não autenticado.' });
|
||||
}
|
||||
|
||||
// Update User model
|
||||
await User.findByIdAndUpdate(req.appUser._id, { lastSeenAt: new Date() });
|
||||
|
||||
// Also update Organization Member for tighter query
|
||||
// But for now User model is enough if we join correctly, or just use User model for presence.
|
||||
// Actually, since we want to show users per organization, we should filter by Org.
|
||||
// Our 'User.ts' has organizationId, but it might be just the 'default' one.
|
||||
// Let's rely on OrganizationMember for the list, but we need to update lastSeenAt there too?
|
||||
// Strategy: Update User (global), and when querying active users, join or filter.
|
||||
// Better: Update OrganizationMember too if we want org-specific presence?
|
||||
// Simpler: Just update User. When fetching active users, we fetch OrganizationMembers and populate User details, filtering by User.lastSeenAt.
|
||||
|
||||
if (!req.appUser) return res.status(401).end();
|
||||
await query('UPDATE users SET last_seen_at = NOW() WHERE id = $1', [req.appUser.id]);
|
||||
res.status(200).send();
|
||||
} catch (error) {
|
||||
// Silent fail for heartbeat
|
||||
console.error('Heartbeat error:', error);
|
||||
res.status(500).send();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get active users in the same organization (seen in last 2 mins)
|
||||
*/
|
||||
export const getActiveUsers = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const organizationId = req.headers['x-organization-id'] as string;
|
||||
const currentUserId = req.appUser?._id;
|
||||
if (!organizationId) return res.json([]);
|
||||
|
||||
if (!organizationId) {
|
||||
return res.status(400).json([]);
|
||||
}
|
||||
const result = await query(
|
||||
`SELECT u.id, u.name, u.email, u.last_seen_at, u.logto_id
|
||||
FROM users u
|
||||
JOIN user_organizations uo ON u.id = uo.user_id
|
||||
WHERE uo.organization_id = $1
|
||||
AND u.last_seen_at > NOW() - INTERVAL '2 minutes'
|
||||
AND u.id != $2`,
|
||||
[organizationId, req.appUser?.id]
|
||||
);
|
||||
|
||||
// Find members of this org
|
||||
const members = await OrganizationMember.find({ organizationId });
|
||||
|
||||
// Get their Auth IDs
|
||||
const externalIds = members.map(m => m.userId);
|
||||
|
||||
// Find Users who were seen recently (2 minutes)
|
||||
const twoMinutesAgo = new Date(Date.now() - 2 * 60 * 1000);
|
||||
|
||||
const activeUsers = await User.find({
|
||||
externalId: { $in: externalIds },
|
||||
lastSeenAt: { $gte: twoMinutesAgo },
|
||||
_id: { $ne: currentUserId } // Optional: exclude self
|
||||
}).select('name email lastSeenAt externalId'); // Only needed fields
|
||||
|
||||
res.json(activeUsers);
|
||||
res.json((result?.rows || []).map(snakeToCamel));
|
||||
} catch (error) {
|
||||
console.error('Error getting active users:', error);
|
||||
res.status(500).json([]);
|
||||
}
|
||||
};
|
||||
|
||||
// Delete organization member
|
||||
export const deleteUser = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
@@ -300,20 +221,20 @@ export const deleteUser = async (req: Request, res: Response) => {
|
||||
return res.status(400).json({ error: 'Organização não selecionada.' });
|
||||
}
|
||||
|
||||
console.log(`Deleting member ${id} from organization ${organizationId}`);
|
||||
const result = await query(
|
||||
'DELETE FROM user_organizations WHERE user_id = $1 AND organization_id = $2 RETURNING *',
|
||||
[id, organizationId]
|
||||
);
|
||||
|
||||
// Delete from OrganizationMember collection
|
||||
const result = await OrganizationMember.findByIdAndDelete(id);
|
||||
|
||||
if (!result) {
|
||||
if (!result?.rowCount) {
|
||||
return res.status(404).json({ error: 'Membro não encontrado.' });
|
||||
}
|
||||
|
||||
console.log(`Member ${result.name} deleted successfully`);
|
||||
|
||||
res.json({ message: 'Membro removido com sucesso.', deletedMember: result });
|
||||
res.json({ message: 'Membro removido com sucesso.' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting user:', error);
|
||||
res.status(500).json({ error: 'Erro ao remover membro.' });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user