Source: utils/organizationUtils.js

/**
 * Organization utilities for handling organization selection and root setting
 *
 * Key Concept: Definition of the organization
 * ===========================================
 * The organization is derived over the object links, never stored on the object
 * itself: the chain runs from the object to its group and up to the root group —
 * the group that marks itself as top level (`memberSys` self-link / `Data.root`).
 *
 * This works uniformly for all object types (person, group, extern, job, guest,
 * list, dlist, email, project, repositoryShare, …). Two depths are covered:
 *
 * - Propagated objects: one hop already reaches the organization
 *   (Person/Group/Extern/Job/Guest → member → Organisation)
 * - Objects that only carry their group link (list, project): one further hop
 *   (List/Projekt → memberA → Gruppe → member → Organisation)
 *
 * A share has no organization link at all: its organization is the one of its
 * project (see getOrganizationForShare).
 *
 *  @import {ExpressRequestAuthorized, ExpressResponse} from './../types.js'
 */
import { query, UUID2hex, HEX2uuid } from '@commtool/sql-query';
import { getConfig } from '../utils/compileTemplates.js';
import { getRedisClient } from '@commtool/shared-auth';
import { getSuperAdmin, getAllOrganizations } from '../Router/orga/service.js';
import { isAdmin, isAdminOrga, isBaseAdmin } from '../utils/authChecks.js';
import { wsFakeUser } from '../server.ws.js';
import { getUser, getFakeUser } from './userUtils.js';

/**
 * Get the required database roles based on NODE_ENV
 * Production: db-user, db-admin
 * Development: db-dev, db-admin
 * Demonstration: db-demo, db-admin
 * @returns {string[]} Array of valid roles for current environment
 */
const getRequiredDbRoles = () => {
    if (process.env.NODE_ENV === 'development') {
        return ['db-dev', 'db-admin'];
    }
    if (process.env.NODE_ENV === 'demonstration') {
        return ['db-demo', 'db-admin'];
    }
    // Production (default)
    return ['db-user', 'db-admin'];
};




/**
 * SQL fragment: is `${alias}` a top-level group (an organization)?
 *
 * Two equivalent markers exist in the data, and both are accepted:
 * - `Data.root = true` (the flag set by the org setup), and
 * - a `memberSys` self-link (`UID = UIDTarget = <group>`), which marks the top
 *   level of the group tree.
 *
 * Test fixtures use the `memberSys` self-link; checking only `Data.root` would
 * miss organizations that carry just that marker.
 *
 * @param {string} alias - the ObjectBase alias
 * @returns {string}
 */
const isRootGroupSql = (alias) => `(
    JSON_VALUE(${alias}.Data, '$.root')
    OR EXISTS (
        SELECT 1 FROM Links AS rootLink
        WHERE rootLink.UID = ${alias}.UID AND rootLink.UIDTarget = ${alias}.UID AND rootLink.Type = 'memberSys'
    )
)`;

/**
 * Get the organization UID for any PROPAGATED object UID (group, person, extern, job, guest)
 * Works because these object links are propagated to their organization via member links.
 *
 * Link Propagation Flow:
 * Propagated Object → member link → ... → Organization (Data.root=true)
 *
 * For NOT propagated objects (list, project) the single hop is not enough: they
 * only link to their group. The second query below covers that case
 * (object → group → organization), so this function answers for all object
 * types except shares — a share's organization is the one of its project.
 *
 * @param {Buffer} objectUID - The object UID (Buffer or hex string)
 * @returns {Promise<string|null>} - The organization UID or null if not found
 */
export const getOrganizationForObject = async (objectUID) => {
    try {
        // Accept both a hex Buffer and a `UUID-…` string (the query parameters are
        // not auto-cast, so a bare string would never match the binary(16) column).
        objectUID = UUID2hex(objectUID);
        // 1. Propagated objects: one hop reaches the organization directly.
        const result = await query(`
            SELECT org.UID AS UIDOrga
            FROM ObjectBase AS obj
            INNER JOIN Links AS objOrgaLink ON (objOrgaLink.UID = obj.UID AND objOrgaLink.Type IN ('member','memberSys', 'memberA','memberS','memberG'))
            INNER JOIN ObjectBase AS org ON (org.UID = objOrgaLink.UIDTarget AND ${isRootGroupSql('org')})
            WHERE obj.UID = ?
            LIMIT 1
        `, [objectUID], { cast: ['UUID'] });

        if (result.length > 0) return result[0].UIDOrga;

        // 2. Not propagated objects (list, project): they carry only the link to
        //    their group, so one more hop is needed to reach the organization.
        const viaGroup = await query(`
            SELECT org.UID AS UIDOrga
            FROM ObjectBase AS obj
            INNER JOIN Links AS objGroupLink ON (objGroupLink.UID = obj.UID AND objGroupLink.Type IN ('member','memberA','memberS','memberG'))
            INNER JOIN Links AS groupOrgaLink ON (groupOrgaLink.UID = objGroupLink.UIDTarget AND groupOrgaLink.Type IN ('member','memberA','memberS','memberG','memberSys'))
            INNER JOIN ObjectBase AS org ON (org.UID = groupOrgaLink.UIDTarget AND ${isRootGroupSql('org')})
            WHERE obj.UID = ?
            LIMIT 1
        `, [objectUID], { cast: ['UUID'] });

        return viaGroup.length > 0 ? viaGroup[0].UIDOrga : null;
    } catch (error) {
        console.error('Error getting organization for object:', error);
        return null;
    }
};

/**
 * Get the organization UID for a given group UID
 * @deprecated Use getOrganizationForObject instead (works for all object types due to link propagation)
 * @param {Buffer} groupUID - The group UID (Buffer or hex string)
 *  @returns {Promise<string|null>} - The organization UID or null if not found
 */
export const getOrganizationForGroup = async (groupUID) => {
    return getOrganizationForObject(groupUID);
};

/**
 * Get the organization UID for a share (repositoryShare / directoryShare).
 *
 * A share carries **no** organization link: the organization is derived over its
 * project (`Share → memberA → Projekt → Owner-Gruppe → Organisation`). While the
 * old model is still in the database (project → share link, `UIDBelongsTo` = org),
 * both link directions and the direct `UIDBelongsTo` fallback are tolerated, so the
 * function answers before, during and after the migration.
 *
 * @param {Buffer|string} shareHex - the share UID (Buffer or hex string)
 * @returns {Promise<string|null>} - the organization UID or null if not found
 */
export const getOrganizationForShare = async (shareHex) => {
    try {
        shareHex = UUID2hex(shareHex);
        // 1. Target model: the share links to its project.
        const [own] = await query(`
            SELECT proj.UID AS project
            FROM Links AS link
            INNER JOIN ObjectBase AS proj ON (proj.UID = link.UIDTarget AND proj.Type = 'project')
            WHERE link.UID = ? AND link.Type IN ('memberA','member')
            LIMIT 1
        `, [shareHex], { cast: ['UUID'] });

        // 2. Old model: the project links to the share.
        const [foreign] = own ? [null] : await query(`
            SELECT proj.UID AS project
            FROM Links AS link
            INNER JOIN ObjectBase AS proj ON (proj.UID = link.UID AND proj.Type = 'project')
            WHERE link.UIDTarget = ? AND link.Type IN ('memberA','member')
            LIMIT 1
        `, [shareHex], { cast: ['UUID'] });

        const project = own?.project ?? foreign?.project ?? null;
        if (project) return getOrganizationForObject(project);

        // 3. Old model without a project link: `UIDBelongsTo` still holds the org.
        const [direct] = await query(`
            SELECT org.UID AS UIDOrga
            FROM ObjectBase AS share
            INNER JOIN ObjectBase AS org ON (org.UID = share.UIDBelongsTo AND ${isRootGroupSql('org')})
            WHERE share.UID = ?
            LIMIT 1
        `, [shareHex], { cast: ['UUID'] });

        return direct?.UIDOrga ?? null;
    } catch (error) {
        console.error('Error getting organization for share:', error);
        return null;
    }
};

/**
 * Get the organization UID for a given list UID
 * @deprecated Delegates to getOrganizationForObject, which covers the
 * list/project case (object → group → organization) as its second step.
 * @param {Buffer} listUID - The list UID (Buffer or hex string)
 *  @returns {Promise<string|null>} - The organization UID or null if not found
 */
export const getOrganizationForList = async (listUID) => {
    return getOrganizationForObject(listUID);
};

/**
 * Does this object belong to the given organization?
 *
 * Replaces the former `ObjectBase.UIDBelongsTo = <org>` filter, which no longer
 * holds once objects carry their self-reference / parent there
 * (project, share). Both sides are compared as canonical UUID strings.
 *
 * @param {Buffer|string} objectUID - the object UID (Buffer or hex string)
 * @param {Buffer|string} orgUID    - the organization UID (Buffer or hex string)
 * @returns {Promise<boolean>}
 */
export const isObjectInOrg = async (objectUID, orgUID) => {
    const org = await getOrganizationForObject(objectUID);
    if (!org) return false;
    return org === HEX2uuid(orgUID);
};

/**
 * The same rule as `getOrganizationForObject`, but as a SQL predicate so a
 * listing can scope many rows in one query instead of resolving each object.
 *
 * Bind the organization **twice** (the predicate uses two placeholders) — both
 * times as the value you would pass as `orgUID`.
 *
 * @param {string} [alias] - the ObjectBase alias the predicate applies to
 * @returns {string} an `EXISTS(...)` expression for a WHERE clause
 */
export const objectInOrgSql = (alias = 'ObjectBase') => `(
    EXISTS (
        SELECT 1 FROM Links AS l1
        INNER JOIN ObjectBase AS o1 ON (o1.UID = l1.UIDTarget AND o1.UID = ? AND ${isRootGroupSql('o1')})
        WHERE l1.UID = ${alias}.UID AND l1.Type IN ('member','memberSys','memberA','memberS','memberG')
    )
    OR EXISTS (
        SELECT 1 FROM Links AS l1
        INNER JOIN Links AS l2 ON (l2.UID = l1.UIDTarget AND l2.Type IN ('member','memberA','memberS','memberG','memberSys'))
        INNER JOIN ObjectBase AS o2 ON (o2.UID = l2.UIDTarget AND o2.UID = ? AND ${isRootGroupSql('o2')})
        WHERE l1.UID = ${alias}.UID AND l1.Type IN ('member','memberA','memberS','memberG')
    )
)`;
/** The number of `?` placeholders `objectInOrgSql` expects. */
export const OBJECT_IN_ORG_PARAMS = 2;

/** this code is obsoleted, as we always get an organisation from keycloak login */
/**
 * Alternative root finding logic when no specific organization is provided
 * @param {Object} req - Express request obfuserject
 * @param {Object} res - Express response object
 * @returns {Promise<Object>} - {user, config} object
 */
export const alternativeRoot = async (req, res) => {
    // Get authenticated user from session (set by sessionEnhancer)
    const authUser = req.session.authUser;
    
    // Try to find the orga the user has in his keycloak data
    const orgas = await query(
        `SELECT UID FROM Objects WHERE Objects.Type='group' AND Objects.UID=Objects.UIDBelongsTo AND JSON_VALUE(Objects.Data,'$.keycloakOrga')=?`,
        [`UUID-${authUser.organization}`],
        { cast: ['UUID'] }
    );

    if (orgas.length > 0) {
        return setOrgaRoot(orgas[0].UID, req);
    }

    // Try to find the first orga the user is member of
    const orgas2 = await query(
        `SELECT Links.UIDTarget AS UID FROM Links
        INNER JOIN Links AS MLink ON (Links.UIDTarget=MLink.UID AND MLink.Type IN ('member','memberA','memberSys'))
        WHERE Links.UID=? AND Links.Type='identifyer'
        ORDER BY MLink.Type DESC LIMIT 1`,
        [UUID2hex(`UUID-${authUser.id}`)],
        { cast: ['UUID'] }
    );

    if (orgas2.length > 0) {
        return setOrgaRoot(orgas2[0].UID, req);
    }

    // If employee, try to find an orga which has no admin list
    if (authUser.groups && authUser.groups.includes('employees')) {
        const orgas3 = await query(
            `SELECT Objects.UID FROM Objects
            WHERE Objects.Type='group' AND Objects.UID=Objects.UIDBelongsTo
            AND NOT EXISTS (SELECT 1 FROM Links WHERE Links.UIDTarget=Objects.UID AND Links.Type='admin')
            ORDER BY Objects.UID LIMIT 1`,
            [],
            { cast: ['UUID'] }
        );

        if (orgas3.length > 0) {
            // Make the session an admin session
            req.session.admin = true;
            return setOrgaRoot(orgas3[0].UID, req);
        }
    }

    return { user: null, config: null };
};


/**
 * Set the organization root for the current session
 * @param {string} UIDroot - Organization UUID
 * @param {Object} req - Express request object
 * @returns {Promise<Object>} - {user, config} object
 */
export const setOrgaRoot = async (UIDroot, req) => {
    // Set both properties at once
    Object.assign(req.session, {
        root: UIDroot,
        UIDroot: UIDroot,
        app: req.params.app ? (req.params.app === 'root' ? 'db' : req.params.app) : (req.session.app ? req.session.app : 'db')
    });

    /**
     * SECURITY-CRITICAL: Distinguish between service/bot and regular user
     * Service accounts in Keycloak are assigned to 'app-bot' group
     */
    const authUser = req.session.authUser;
    // Handle groups as array or string (Keycloak can return either format)
    const groups = Array.isArray(authUser?.groups) ? authUser.groups : (authUser?.groups ? [authUser.groups] : []);
    const isServiceClient = groups.includes('app-bot') || req.session.superadmin;

    let result;
    if (isServiceClient) {
        // Handle service client/bot requests - assign cached super admin user
        try {
            // Simplified super admin caching - auth library handles bot user identity
            const superAdminKey = `superadmin-${UIDroot}`;
            let superAdmin = await getRedisClient().get(superAdminKey);

            if (!superAdmin) {
                // Cache miss - fetch from database
                superAdmin = await getSuperAdmin(UIDroot);

                if (superAdmin) {
                    // Cache for 1 hour (3600 seconds)
                    await getRedisClient().setEx(superAdminKey, 3600, superAdmin);
                }
            }

            if (superAdmin) {
                // Assign super admin as bot user (auth library handles the detailed user object)
                req.session.user = superAdmin;
                req.session.botUser = !req.session.superadmin; // Keep for logging purposes only
                req.session.baseUser = superAdmin;
                let userOrgas=[]
                if(req.session.superadmin)
                {
                    // get all orgas for superadmin
                    const orgasResult = await query(`
                        SELECT orga.UID FROM ObjectBase as orga
                        INNER JOIN Links AS mslink ON (mslink.UID=orga.UID AND mslink.Type ='memberSys' AND mslink.UID=mslink.UIDTarget)
                        WHERE orga.Type='group'
                    `,
                    [UUID2hex(superAdmin)],
                    { cast: ['UUID'] }
                    );
                    userOrgas=orgasResult.map(o=>o.UID)
                }
                const config = await getConfig(req.session.app, req);
                const user= { UID: superAdmin, isAdmin: true, Data: { firstName: 'super', lastName: 'admin',userOrgas } };
                const mergedConfig = { ...config, sysUserUID: superAdmin, orgaUser: user };

                result = { user: user, config: mergedConfig };
            } else {
                console.warn(`[setOrgaRoot] No super admin found for org: ${UIDroot}`);
                result = { user: null, config: null };
            }
        } catch (error) {
            console.error(`[setOrgaRoot] Error assigning bot user: ${error.message}`);
            result = { user: null, config: null };
        }
    } else {
        // Handle regular user requests
        const user = await getUser(req);
        let fakeUser = null;
        
        // Check for user impersonation via X-Fake-User header (admin only)
        const fakeUserHeader = req.headers['x-fake-user'];
        if (fakeUserHeader) {
            // First check if requester is admin before allowing impersonation
            req.session.user = user.UID;
            req.session.baseUser = user.UID;
            const isUserAdmin = await isAdmin(req.session);
            
            if (isUserAdmin) {
                fakeUser = await getFakeUser(fakeUserHeader, UIDroot);
            } else {
                console.warn(`[setOrgaRoot] Non-admin user ${user.UID} attempted to impersonate ${fakeUserHeader}`);
            }
        }        const app = req.session.app === 'root' ? 'db' : req.session.app;
        if (fakeUser) {
            // Fake a user for admins (impersonation for testing/support)
            req.session.user = fakeUser.UID;
            req.session.fakeLogin = fakeUserHeader;
            req.session.baseUser = user.UID;
            const config = await getConfig(req.session.app, req);
            const mergedConfig = { ...config, sysUserUID: req.session.sysUser, orgaUser: fakeUser };
            req.session.admin = false; // Admin rights not transferred to fake user
            req.session.sysUser = mergedConfig.sysUser;
            req.session.app = app;
            req.session.sysUser = mergedConfig.sysUser         
            result = { user: fakeUser, config: mergedConfig };
        } else {
            // Standard user settings
            req.session.user = user.UID;
            req.session.baseUser = user.UID;
            // Get org roles from authenticated user (set by sessionEnhancer)
            const authUser = req.session.authUser;
            const config = await getConfig(req.session.app, req);
            const mergedConfig = { ...config, sysUserUID: req.session.sysUser, orgaUser: user };
            req.session.admin = await isAdmin(req.session);
            req.session.app = app;
            req.session.sysUser = mergedConfig.sysUser;
            result = { user, config: mergedConfig };
        }
    }


    return result;
};

/**
 * Check and set the organization root for the current request
 * Uses Keycloak organization as single source of truth
 * 
 * Answers the request itself (does not call `next`) when the organization context is unusable:
 * - `400` with `missingOrga: true` - `x-organization`/`x-orga` missing or not a UUID
 * - `403` - `x-user-uid` was sent, but that user is not valid for the organization
 * - `500` - the organization config could not be loaded
 * 
 * @param {ExpressRequestAuthorized} req - Express request object
 * @param {Object} res - Express response object
 * @param {Function} next - Express next function
 */


export const checkRoot = async (req, res, next) => {
    try {
        // Organisation context comes from the `x-organization` header (alias `x-orga`),
        // which the frontend service worker and the bot/API clients set. There is no
        // token-claim fallback here - only the public portal routes resolve it themselves.
        const targetOrgHeader = req.headers['x-organization'] || req.headers['x-orga'];
        /**   @type {any} */ (req.session).superadmin = req.headers['x-superadmin-uid'] === 'true';
        // Duplicate headers arrive as an array - normalize to a single value.
        let targetOrg = Array.isArray(targetOrgHeader) ? targetOrgHeader[0] : targetOrgHeader;
    

          
        // Missing organisation context: neither `x-organization` nor `x-orga` was sent.
        // Before, `targetOrg.match()` threw a TypeError on `undefined` and the catch
        // below turned this client error into a 500 "Error processing organization context".
        if (!targetOrg) {
            console.warn('[checkRoot] Missing organization header (x-organization / x-orga)');
            return res.status(400).json({ 
                success: false, 
                missingOrga: true, 
                message: 'Missing organization header (x-organization or x-orga)' 
            });
        }

        // Validate organization format
        if (!targetOrg.match(/^UUID-[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/)) {
            console.warn(`[checkRoot] Invalid organization format: ${targetOrg}`);
            return res.status(400).json({ 
                success: false, 
                missingOrga: true, 
                message: 'Invalid organization format' 
            });
        }
        
        
        /** @type {any} */ req.session.root=targetOrg;
        
                    
        // Set the new organization root
        const { user, config } = await setOrgaRoot(targetOrg, req);
        
        // Handle optional user override header (for testing/bots)
        const userHeader = req.headers['x-user-uid'];
        if (userHeader && userHeader.match(/^UUID-[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/)) {
            const { validateUserForOrganization } = await import('./userUtils.js');
            const userValidation = await validateUserForOrganization(userHeader, targetOrg);
            
            if (userValidation.valid) {
                req.session.user = userHeader;
                /** @type {any} */ (req.session).baseUser = userHeader;
            } else {
                console.warn(`[checkRoot] ❌ User ${userHeader} not valid for org ${targetOrg}`);
                return res.status(403).json({ 
                    success: false, 
                    message: 'User not authorized for organization' 
                });
            }
        }
        
        // Handle super admin override header (for bots)
        const superAdminHeader = req.headers['x-superadmin-uid'];
        if (superAdminHeader && (!user || user.UID === null)) {
            req.session.user = superAdminHeader;
            /** @type {any} */ (req.session).botUser = true;
            /** @type {any} */ (req.session).baseUser = superAdminHeader;
            
            // Cache super admin UID
            const superAdminKey = `superadmin-${targetOrg}`;
            await getRedisClient().setEx(superAdminKey, 3600, superAdminHeader);
        }
        
        if (!config) {
            console.error(`[checkRoot] Failed to load config for organization: ${targetOrg}`);
            return res.status(500).json({ 
                success: false, 
                message: 'Failed to load organization configuration' 
            });
        }
        // Note: With Bearer-only auth (v7.0), session data is stored in the virtual session object
        // attached to req.session by authFactory. No need to persist to Redis.
        
        
        next();
        
    } catch (error) {
        console.error(`[checkRoot] Error: ${error.message}`);
        return res.status(500).json({ 
            success: false, 
            message: 'Error processing organization context' 
        });
    }
};