diff --git a/db-migrate.cjs b/db-migrate.cjs new file mode 100644 index 0000000..3f1c666 --- /dev/null +++ b/db-migrate.cjs @@ -0,0 +1,14 @@ +const DBMigrate = require('db-migrate'); + +const url = new URL(process.env.DATABASE_URL); +url.searchParams.set('multipleStatements', 'true'); +process.env.DATABASE_URL = url; + +const dbmigrate = DBMigrate.getInstance(true); + +if (process.argv[2] === 'up') + dbmigrate.up(); +else if (process.argv[2] == 'down') + dbmigrate.down(); +else + console.error('Unknown action', argv[2]); diff --git a/migrations/20240328113145-modify-team-user-ext-fk.js b/migrations/20240328113145-modify-team-user-ext-fk.js new file mode 100644 index 0000000..8d2c437 --- /dev/null +++ b/migrations/20240328113145-modify-team-user-ext-fk.js @@ -0,0 +1,53 @@ +'use strict'; + +var dbm; +var type; +var seed; +var fs = require('fs'); +var path = require('path'); +var Promise; + +/** + * We receive the dbmigrate dependency from dbmigrate initially. + * This enables us to not have to rely on NODE_PATH. + */ +exports.setup = function(options, seedLink) { + dbm = options.dbmigrate; + type = dbm.dataType; + seed = seedLink; + Promise = options.Promise; +}; + +exports.up = function(db) { + var filePath = path.join(__dirname, 'sqls', '20240328113145-modify-team-user-ext-fk-up.sql'); + return new Promise( function( resolve, reject ) { + fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){ + if (err) return reject(err); + console.log('received data: ' + data); + + resolve(data); + }); + }) + .then(function(data) { + return db.runSql(data); + }); +}; + +exports.down = function(db) { + var filePath = path.join(__dirname, 'sqls', '20240328113145-modify-team-user-ext-fk-down.sql'); + return new Promise( function( resolve, reject ) { + fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){ + if (err) return reject(err); + console.log('received data: ' + data); + + resolve(data); + }); + }) + .then(function(data) { + return db.runSql(data); + }); +}; + +exports._meta = { + "version": 1 +}; diff --git a/migrations/sqls/20240328113145-modify-team-user-ext-fk-down.sql b/migrations/sqls/20240328113145-modify-team-user-ext-fk-down.sql new file mode 100644 index 0000000..c41a743 --- /dev/null +++ b/migrations/sqls/20240328113145-modify-team-user-ext-fk-down.sql @@ -0,0 +1,5 @@ +ALTER TABLE actaeon_user_ext + DROP CONSTRAINT fk_team; +ALTER TABLE actaeon_user_ext + ADD CONSTRAINT fk_team FOREIGN KEY(team) REFERENCES actaeon_teams(uuid) + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/migrations/sqls/20240328113145-modify-team-user-ext-fk-up.sql b/migrations/sqls/20240328113145-modify-team-user-ext-fk-up.sql new file mode 100644 index 0000000..5a7643a --- /dev/null +++ b/migrations/sqls/20240328113145-modify-team-user-ext-fk-up.sql @@ -0,0 +1,5 @@ +ALTER TABLE actaeon_user_ext + DROP CONSTRAINT fk_team; +ALTER TABLE actaeon_user_ext + ADD CONSTRAINT fk_team FOREIGN KEY(team) REFERENCES actaeon_teams(uuid) + ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/next.config.mjs b/next.config.mjs index ce0e518..e223baa 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -34,7 +34,7 @@ const nextConfig = { }, productionBrowserSourceMaps: true, webpack: config => { - config.externals = [...config.externals, 'bcrypt']; + config.externals = [...config.externals, 'bcrypt', 'mysql2']; return config; } }; diff --git a/package.json b/package.json index 5772da8..45114ea 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,9 @@ "build": "next build", "start": "next start", "lint": "next lint", - "migrate:up": "dotenvx run -f .env.local -- db-migrate up", + "migrate:up": "dotenvx run -f .env.local -- node db-migrate.cjs up", "migrate": "npm run migrate:up", - "migrate:down": "dotenvx run -f .env.local -- db-migrate down", + "migrate:down": "dotenvx run -f .env.local -- node db-migrate.cjs down", "migrate:create": "dotenvx run -f .env.local -- db-migrate create", "db:export": "dotenvx run -f .env.local -- kysely-codegen --exclude-pattern cozynet* --exclude-pattern alembic_version --exclude-pattern actaeon_migrations --out-file src/types/db.d.ts && node process-db.js" }, diff --git a/src/actions/team.ts b/src/actions/team.ts new file mode 100644 index 0000000..557a757 --- /dev/null +++ b/src/actions/team.ts @@ -0,0 +1,224 @@ +'use server'; + +import { ActionResult } from '@/types/action-result'; +import { requireUser } from './auth'; +import { db } from '@/db'; +import crypto from 'crypto'; +import { JoinPrivacy, PRIVACY_VALUES, VISIBILITY_VALUES, Visibility } from '@/types/privacy-visibility'; +import { syncUserTeams } from '@/data/team'; +import { notFound, redirect } from 'next/navigation'; +import { UserPermissions } from '@/types/permissions'; +import { hasPermission, requirePermission } from '@/helpers/permissions'; +import { makeValidator } from '@/types/validator-map'; +import { DB } from '@/types/db'; +import { randomString } from '@/helpers/random'; + +export const createTeam = async (name: string): Promise => { + const user = await requireUser(); + + if (user.team) + return { error: true, message: 'You are already part of a team' }; + + name = name.trim(); + const existingTeam = await db.selectFrom('actaeon_teams') + .where(({ fn, eb }) => eb(fn('lower', ['name']), '=', name.toLowerCase())) + .select('name') + .executeTakeFirst(); + + if (existingTeam) + return { error: true, message: 'A team with that name already exists' }; + + const uuid = crypto.randomUUID(); + + await db.transaction().execute(async trx => { + const chuniTeam = Number((await db.insertInto('chuni_profile_team') + .values({ + teamName: name, + teamPoint: 0 + }) + .executeTakeFirst()).insertId); + + await db.insertInto('actaeon_teams') + .values({ + uuid, + name, + visibility: Visibility.PRIVATE, + joinPrivacy: JoinPrivacy.INVITE_ONLY, + owner: user.id!, + chuniTeam + }) + .executeTakeFirst(); + + await db.updateTable('actaeon_user_ext') + .where('userId', '=', user.id) + .set({ team: uuid } ) + .executeTakeFirst(); + + await syncUserTeams(user.id, { chuniTeam }, trx); + }); + + redirect(`/team/${uuid}`); +}; + +const requireOwner = async ({ team, orPermission, teamData }: { + team: string, + orPermission?: UserPermissions, + teamData?: DB['actaeon_teams'] +}) => { + const user = await requireUser(); + + if (!teamData) + teamData = await db.selectFrom('actaeon_teams') + .where('uuid', '=', team) + .selectAll() + .executeTakeFirst(); + + if (!teamData) + notFound(); + + if (hasPermission(user.permissions, orPermission ?? UserPermissions.OWNER)) + return teamData; + + if (teamData.owner !== user.id) + redirect('/forbidden'); + + return teamData; +} + +export type TeamUpdate = Partial<{ + name: string, + joinPrivacy: JoinPrivacy, + visibility: Visibility +}>; + +const validator = makeValidator() + .nonNullableKeys('name', 'joinPrivacy', 'visibility') + .withValidator('name', async (val, team) => { + val = val.trim(); + + const existingTeam = await db.selectFrom('actaeon_teams') + .where(({ fn, eb, and }) => and([ + eb(fn('lower', ['name']), '=', val.toLowerCase()), + ...(team ? [eb('uuid', '!=', team.uuid)] : []) + ])) + .select('uuid') + .executeTakeFirst(); + + if (existingTeam) + throw new Error('A team with that name already exists'); + + return val; + }) + .withValidator('joinPrivacy', val => { + if (!PRIVACY_VALUES.has(val)) + throw new Error('Invalid privacy value'); + }) + .withValidator('visibility', val => { + if (!VISIBILITY_VALUES.has(val)) + throw new Error('Invalid visibility value'); + }); + +export const modifyTeam = async (team: string, update: TeamUpdate): Promise> => { + const teamData = await requireOwner({ team }); + const res = await validator.validate(update, teamData); + if (res.error) return res; + + await db.updateTable('actaeon_teams') + .where('uuid', '=', team) + .set(update) + .executeTakeFirst(); + + return {}; +}; + +export const joinPublicTeam = async (team: string) => { + const user = await requireUser(); + const teamData = await db.selectFrom('actaeon_teams') + .where('uuid', '=', team) + .selectAll() + .executeTakeFirst(); + + if (!teamData) + return notFound(); + + if (teamData.joinPrivacy !== JoinPrivacy.PUBLIC) + return requirePermission(user.permissions, UserPermissions.OWNER); + + if (user.team) + return { error: true, message: 'You are already part of a team' }; + + await db.transaction().execute(async trx => { + await trx.updateTable('actaeon_user_ext') + .where('userId', '=', user.id) + .set({ team }) + .executeTakeFirst(); + + await syncUserTeams(user.id, teamData, trx); + }); +}; + +export const removeUserFromTeam = async (team: string, userId?: number) => { + const user = await requireUser(); + userId ??= user.id; + + const teamData = await db.selectFrom('actaeon_teams') + .where('uuid', '=', team) + .selectAll() + .executeTakeFirst(); + + if (!teamData) return notFound() + + if (userId === teamData.owner) + return { error: true, message: 'The owner of this team cannot be removed' }; + + if (user.id !== userId) + await requireOwner({ team, teamData, orPermission: UserPermissions.USERMOD }); + + await db.transaction().execute(async trx => { + await trx.updateTable('actaeon_user_ext') + .where('userId', '=', userId) + .where('team', '=', team) + .set({ team: null }) + .executeTakeFirst(); + + await syncUserTeams(userId, null, trx); + }); +}; + +export const deleteTeam = async (team: string) => { + const teamData = await requireOwner({ team }); + + await db.transaction().execute(async trx => { + await trx.deleteFrom('chuni_profile_team') + .where('id', '=', teamData.chuniTeam) + .executeTakeFirst(); + + await trx.deleteFrom('actaeon_teams') + .where('uuid', '=', teamData.uuid) + .executeTakeFirst(); + }); +}; + +export const deleteTeamLink = async (team: string, link: string) => { + const teamData = await requireOwner({ team }); + await db.deleteFrom('actaeon_team_join_keys') + .where('teamId', '=', teamData.uuid) + .where('id', '=', link) + .executeTakeFirst(); +}; + +export const createTeamLink = async (team: string, remainingUses: number | null) => { + const teamData = await requireOwner({ team }); + const id = randomString(10); + + await db.insertInto('actaeon_team_join_keys') + .values({ + id, + teamId: team, + remainingUses, + totalUses: 0 + }) + .executeTakeFirst(); + + return id; +}; diff --git a/src/app/(with-header)/arcade/page.tsx b/src/app/(with-header)/arcade/page.tsx index fe62297..e5e152a 100644 --- a/src/app/(with-header)/arcade/page.tsx +++ b/src/app/(with-header)/arcade/page.tsx @@ -1,10 +1,10 @@ import { Arcade, getArcades } from '@/data/arcade'; import { getUser } from '@/actions/auth'; import { Divider, Tooltip } from '@nextui-org/react'; -import { GlobeAltIcon, LinkIcon, LockClosedIcon, UserGroupIcon } from '@heroicons/react/24/outline'; -import { Visibility } from '@/types/privacy-visibility'; +import { UserGroupIcon } from '@heroicons/react/24/outline'; import Link from 'next/link'; import { CreateArcadeButton } from '@/components/create-arcade-button'; +import { VisibilityIcon } from '@/components/visibility-icon'; const getLocation = (arcade: Arcade) => { let out = [arcade.city, arcade.state, arcade.country].filter(x => x).join(', '); @@ -28,15 +28,7 @@ export default async function ArcadePage() { {arcades.map(arcade =>
- {arcade.visibility === Visibility.PUBLIC && - - } - {arcade.visibility === Visibility.UNLISTED && - - } - {arcade.visibility === Visibility.PRIVATE && - - } +
{arcade.uuid ? {arcade.name} : arcade.name} diff --git a/src/app/(with-header)/team/[teamId]/join/[join]/page.tsx b/src/app/(with-header)/team/[teamId]/join/[join]/page.tsx new file mode 100644 index 0000000..ffe2705 --- /dev/null +++ b/src/app/(with-header)/team/[teamId]/join/[join]/page.tsx @@ -0,0 +1,60 @@ +import { requireUser } from '@/actions/auth'; +import { InvalidLink } from '@/components/invalid-link'; +import { JoinSuccess } from '@/components/join-success'; +import { syncUserTeams } from '@/data/team'; +import { db } from '@/db'; +import { ExclamationCircleIcon } from '@heroicons/react/24/outline'; +import { redirect } from 'next/navigation'; + +export default async function Join({ params }: { params: { teamId: string, join: string; }; }) { + const user = await requireUser(); + + if (!params.join) + return (); + + if (user.team === params.teamId) + return redirect(`/team/${params.teamId}`) + + if (user.team) + return (
+ +
You are already part of a team.
+ You must leave your current team before joining a new one. +
); + + const joinLink = await db.selectFrom('actaeon_teams as team') + .innerJoin('actaeon_team_join_keys as key', 'key.teamId', 'team.uuid') + .where('team.uuid', '=', params.teamId) + .where('key.id', '=', params.join) + .select(['key.teamId', 'key.remainingUses', 'key.totalUses', 'key.id', 'team.chuniTeam']) + .executeTakeFirst(); + + if (!joinLink) + return (); + + await db.transaction().execute(async trx => { + await trx.updateTable('actaeon_user_ext') + .where('userId', '=', user.id) + .set({ team: params.teamId }) + .executeTakeFirst(); + + if (joinLink.remainingUses !== null && joinLink.remainingUses <= 1) + await trx.deleteFrom('actaeon_team_join_keys') + .where('id', '=', joinLink.id) + .executeTakeFirst(); + else + await trx.updateTable('actaeon_team_join_keys') + .where('id', '=', joinLink.id) + .set({ + totalUses: joinLink.totalUses + 1, + ...(joinLink.remainingUses ? { + remainingUses: joinLink.remainingUses - 1 + } : {}) + }) + .executeTakeFirst() + + await syncUserTeams(user.id, joinLink, trx); + }); + + return () +} \ No newline at end of file diff --git a/src/app/(with-header)/team/[teamId]/page.tsx b/src/app/(with-header)/team/[teamId]/page.tsx new file mode 100644 index 0000000..ad85ae1 --- /dev/null +++ b/src/app/(with-header)/team/[teamId]/page.tsx @@ -0,0 +1,23 @@ +import { getUser } from '@/actions/auth'; +import { getTeamInviteLinks, getTeams, getTeamUsers } from '@/data/team'; +import { notFound } from 'next/navigation'; +import { PrivateVisibilityError } from '@/components/private-visibility-error'; +import { TeamDetail } from '@/components/team'; + +export default async function TeamDetailPage({ params }: { params: { teamId: string }}) { + const user = await getUser(); + const team = (await getTeams({ user, uuids: [params.teamId], showUnlisted: true }))[0]; + + if (!team) + return notFound(); + + const [users, links] = await Promise.all([ + getTeamUsers({ user, team }), + getTeamInviteLinks({ user, team }) + ]); + + if (!team.visible) + return ; + + return () +} diff --git a/src/app/(with-header)/team/page.tsx b/src/app/(with-header)/team/page.tsx new file mode 100644 index 0000000..506130c --- /dev/null +++ b/src/app/(with-header)/team/page.tsx @@ -0,0 +1,53 @@ +import { getUser } from '@/actions/auth'; +import { CreateTeamButton } from '@/components/create-team-button'; +import { VisibilityIcon } from '@/components/visibility-icon'; +import { getTeams } from '@/data/team'; +import { UserGroupIcon } from '@heroicons/react/24/outline'; +import { StarIcon } from '@heroicons/react/24/solid'; +import { Divider, Tooltip } from '@nextui-org/react'; +import Link from 'next/link'; + +export default async function TeamPage() { + const user = await getUser(); + const teams = (await getTeams({ user })) + .filter(t => t.visible); + + return (
+
+ Teams + + {!teams[0]?.isMember && } +
+ + + +
+ {!teams.length && No teams found} + + {teams.map(team =>
+ + + {team.name} + + {team.isMember && + + } + + +
+ + {team.userCount?.toString()} +
+
+ + + Leader:  + {team.ownerUsername ? + {team.ownerUsername} + : anonymous user} + +
)} +
+
); +} diff --git a/src/components/arcade.tsx b/src/components/arcade.tsx index 6b1eb1a..dd68b91 100644 --- a/src/components/arcade.tsx +++ b/src/components/arcade.tsx @@ -20,6 +20,8 @@ import { XMarkIcon } from '@heroicons/react/20/solid'; import { JoinLinksModal } from '@/components/join-links-modal'; import { useRouter, useSearchParams } from 'next/navigation'; import { PermissionEditModal, PermissionEditModalUser } from '@/components/permission-edit-modal'; +import { VisibilityIcon } from './visibility-icon'; +import { VisibilityDropdown } from './visibility-dropdown'; export type ArcadeProps = { arcade: Arcade, @@ -136,17 +138,6 @@ export const ArcadeDetail = ({ arcade: initialArcade, users: initialUsers, cabs: onChange={ev => setArcade(a => ({ ...a, [k]: ev.target.value }))} />); }; - const visibilityIcon = (<> - {arcade.visibility === Visibility.PUBLIC && - - } - {arcade.visibility === Visibility.UNLISTED && - - } - {arcade.visibility === Visibility.PRIVATE && - - }); - return (
deleteArcadeLink(arcade.id, id)} @@ -163,42 +154,18 @@ export const ArcadeDetail = ({ arcade: initialArcade, users: initialUsers, cabs: setUserArcadePermissions({ arcadeUser: user, permissions, arcade: arcade.id }); }} />
- {editing ? - <> - - - - - typeof s !== 'string' && s.size && setArcade( - a => ({ ...a, visibility: +[...s][0] }))}> - }> - Private - - }> - Unlisted - - }> - Public - - - - setArcade(a => ({ ...a, name: ev.target.value }))} - classNames={{ - input: 'text-4xl leading-none', - inputWrapper: 'h-24' - }} /> - : - <>{visibilityIcon} {arcade.name}} + setArcade(a => ({ ...a, visibility: v }))} /> + {editing ? + setArcade(a => ({ ...a, name: ev.target.value }))} + classNames={{ + input: 'text-4xl leading-none', + inputWrapper: 'h-24' + }} /> + : + arcade.name}
{editing ?
@@ -271,7 +238,7 @@ export const ArcadeDetail = ({ arcade: initialArcade, users: initialUsers, cabs:
Users - {arcade.joinPrivacy === JoinPrivacy.PUBLIC && !arcade.permissions && + {(arcade.joinPrivacy === JoinPrivacy.PUBLIC || hasPermission(user?.permissions, UserPermissions.ACMOD)) && !arcade.permissions && + ); +}; \ No newline at end of file diff --git a/src/components/header-sidebar.tsx b/src/components/header-sidebar.tsx index ddbb489..48290a5 100644 --- a/src/components/header-sidebar.tsx +++ b/src/components/header-sidebar.tsx @@ -151,7 +151,7 @@ export const HeaderSidebar = ({ children }: HeaderSidebarProps) => {
{routeGroup.routes?.filter(filter).map(renderHeaderLink)}
- {routeGroup !== MAIN_ROUTES &&
+ {routeGroup !== MAIN_ROUTES &&
{MAIN_ROUTES.routes.filter(filter).map(renderHeaderLink)}
}
diff --git a/src/components/prompt-modal.tsx b/src/components/prompt-modal.tsx index 8a301cb..ffac581 100644 --- a/src/components/prompt-modal.tsx +++ b/src/components/prompt-modal.tsx @@ -56,7 +56,7 @@ export const PromptProvider = ({ children }: { children: ReactNode }) => { + +
+ : <> +
+ Chunithm Team Points: + {team.chuniTeamPoint?.toLocaleString()} +
+ +
+ Join Privacy: + {team.joinPrivacy === JoinPrivacy.PUBLIC ? 'Public' : 'Invite Only'} +
+ + {isOwner && + + } + } + +
+ + + +
+
+ Users + + {(team.joinPrivacy === JoinPrivacy.PUBLIC || isOwner) && !isMember && + !user?.team && + + } + + {!isOwner && isMember && Leave this team}> + + } + + {isOwner && + + } +
+ + {!users.length && This arcade has no users} + +
+ {users.map((teamUser, index) =>
+ {teamUser.uuid ? + {teamUser.username} + : Anonymous User} + + {isOwner && teamUser.uuid !== user?.uuid && Kick user}> + + } +
)} +
+
+ + {isOwner && <> + + +
+
+ Management +
+ + +
+ } +
); +}; diff --git a/src/components/visibility-dropdown.tsx b/src/components/visibility-dropdown.tsx new file mode 100644 index 0000000..2ccb22e --- /dev/null +++ b/src/components/visibility-dropdown.tsx @@ -0,0 +1,43 @@ +import { Visibility } from '@/types/privacy-visibility'; +import { VisibilityIcon } from './visibility-icon'; +import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger } from '@nextui-org/react'; +import { ChevronDownIcon, GlobeAltIcon, LinkIcon, LockClosedIcon } from '@heroicons/react/24/outline'; + +type VisibilityDropdownProps = { + visibility: Visibility, + editing: boolean, + loading: boolean, + onVisibilityChange: (visibility: Visibility) => void +}; + +export const VisibilityDropdown = ({ visibility, editing, loading, onVisibilityChange }: VisibilityDropdownProps) => { + const icon = (); + + if (!editing) + return icon; + + return ( + + + + typeof s !== 'string' && s.size && onVisibilityChange(+[...s][0] as any)}> + }> + Private + + }> + Unlisted + + }> + Public + + + ); +}; diff --git a/src/components/visibility-icon.tsx b/src/components/visibility-icon.tsx new file mode 100644 index 0000000..afe1aa4 --- /dev/null +++ b/src/components/visibility-icon.tsx @@ -0,0 +1,19 @@ +import { GlobeAltIcon, LinkIcon, LockClosedIcon } from '@heroicons/react/24/outline'; +import { Visibility } from '@/types/privacy-visibility'; +import { Tooltip } from '@nextui-org/react'; + +export const VisibilityIcon = ({ visibility, className }: { visibility: Visibility, className?: string; }) => { + if (visibility === Visibility.PUBLIC) + return ( + + ); + + if (visibility === Visibility.UNLISTED) + return ( + + ); + + return ( + + ); +}; \ No newline at end of file diff --git a/src/data/team.ts b/src/data/team.ts new file mode 100644 index 0000000..1bb46a1 --- /dev/null +++ b/src/data/team.ts @@ -0,0 +1,148 @@ +import { GeneratedDB, db } from '@/db'; +import { JoinPrivacy, Visibility } from '@/types/privacy-visibility'; +import crypto from 'crypto'; +import { userIsVisible, withUsersVisibleTo } from './user'; +import { Transaction } from 'kysely'; +import { UserPayload } from '@/types/user'; +import { hasPermission } from '@/helpers/permissions'; +import { UserPermissions } from '@/types/permissions'; + +const createActaeonTeamsFromExistingTeams = async () => { + await db.transaction().execute(async trx => { + const chuniTeams = (await trx.selectFrom('chuni_profile_team as chuni') + .leftJoin('actaeon_teams as teams', 'teams.chuniTeam', 'chuni.id') + .where('teams.chuniTeam', 'is', null) + .select(({ selectFrom }) => [ + 'chuni.id', 'chuni.teamName', + selectFrom('chuni_profile_data as profile') + .whereRef('profile.teamId', '=', 'chuni.id') + .select('profile.user') + .limit(1) + .as('owner') + ] as const) + .execute()) + .filter(x => x.owner !== null); + + if (!chuniTeams.length) return; + + const insertValues = chuniTeams.map(team => ({ + uuid: crypto.randomUUID(), + visibility: Visibility.PRIVATE, + joinPrivacy: JoinPrivacy.INVITE_ONLY, + name: team.teamName, + owner: team.owner!, + chuniTeam: team.id! + })); + + await trx.insertInto('actaeon_teams') + .values(insertValues) + .executeTakeFirst(); + + for (const val of insertValues) { + await trx.updateTable('actaeon_user_ext') + .where('userId', '=', val.owner) + .set({ team: val.uuid }) + .executeTakeFirst(); + } + }); +}; + +export const getTeams = async ({ showUnlisted, uuids, user }: + { showUnlisted?: boolean, uuids?: string[], user?: UserPayload | null; }) => { + await createActaeonTeamsFromExistingTeams().catch(console.error); + + const res = await withUsersVisibleTo(user) + .selectFrom('actaeon_teams as team') + .leftJoin('chuni_profile_team as chuni', 'team.chuniTeam', 'chuni.id') + .leftJoin('actaeon_user_ext as ext', 'ext.userId', 'team.owner') + .leftJoin('aime_user as owner', 'owner.id', 'team.owner') + .select(({ selectFrom, eb }) => [ + 'team.uuid', 'team.visibility', 'team.joinPrivacy', 'team.name', + 'owner.username as ownerUsername', 'ext.uuid as ownerUuid', + userIsVisible('owner.id').as('ownerVisible'), + selectFrom('actaeon_user_ext as uext2') + .whereRef('uext2.team', '=', 'team.uuid') + .select(({ fn }) => fn.count('uext2.uuid').as('count')) + .as('userCount'), + eb('team.uuid', '=', user?.team!).as('isMember'), + eb.or([ + eb('team.visibility', '=', Visibility.PUBLIC), + eb('team.uuid', '=', user?.team!), + ...(showUnlisted ? [ + eb('team.visibility', '=', Visibility.UNLISTED) + ] : []), + ]).as('visible'), + 'chuni.teamPoint as chuniTeamPoint' + ] as const) + .where(({ and, eb }) => and([ + ...(uuids?.length ? [ + eb('team.uuid', 'in', uuids) + ] : [eb.lit(true)]) + ])) + .execute(); + + const userTeam = res.findIndex(t => t.uuid === user?.team); + if (userTeam !== -1) + res.unshift(...res.splice(userTeam, 1)); + + return res.map(({ ownerUsername, ownerUuid, ownerVisible, ...rest }) => ({ + ...rest, + ownerUsername: ownerVisible ? ownerUsername : null, + ownerUuid: ownerVisible ? ownerUuid : null + })); +}; + +export type Team = Awaited>[number]; + +export const syncUserTeams = async (user: number, team?: { chuniTeam: number } | null, transaction?: Transaction) => { + const cb = async (trx: Transaction) => { + if (team === undefined) + team = await db.selectFrom('actaeon_user_ext as ext') + .where('ext.userId', '=', user) + .innerJoin('actaeon_teams as teams', 'teams.uuid', 'ext.team') + .select('teams.chuniTeam') + .executeTakeFirst(); + + await db.updateTable('chuni_profile_data') + .where('user', '=', user) + .set({ teamId: team?.chuniTeam ?? null }) + .executeTakeFirst(); + }; + + if (transaction) + await cb(transaction) + else + await db.transaction().execute(cb); +}; + +export const getTeamUsers = async ({ user, team }: { user?: UserPayload | null, team: Team }) => { + const res = await withUsersVisibleTo(user, { allTeam: !!team.ownerUuid && team.ownerUuid === user?.uuid }) + .selectFrom('actaeon_user_ext as ext') + .leftJoin('aime_user as u', 'u.id', 'ext.userId') + .where('ext.team', '=', team.uuid) + .select(['u.username', 'ext.uuid', userIsVisible('u.id').as('visible'), 'u.id']) + .execute(); + + const data = res.map(({ username, uuid, visible, id }) => ({ + uuid: visible ? uuid : null, + username: visible ? username : null, + visible, + isOwner: uuid === team.ownerUuid, + id: visible ? id : null + })); + + data.unshift(...data.splice(data.findIndex(u => u.isOwner), 1)); + return data; +}; + +export type TeamUser = Awaited>[number]; + +export const getTeamInviteLinks = async ({ user, team }: { user?: UserPayload | null, team: Team }) => { + if (!hasPermission(user?.permissions, UserPermissions.OWNER) && user?.uuid !== team.ownerUuid) + return []; + + return db.selectFrom('actaeon_team_join_keys') + .where('teamId', '=', team.uuid) + .selectAll() + .execute(); +}; diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 6041fe7..68e5ab4 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -1,10 +1,16 @@ export async function register() { if (process.env.NEXT_RUNTIME === 'nodejs') { if (['true', 'yes', '1'].includes(process.env.AUTOMIGRATE?.toLowerCase()!)) { + const url = new URL(process.env.DATABASE_URL!); + url.searchParams.set('multipleStatements', 'true'); + process.env.DATABASE_URL = url.toString(); // using require here increases build times to like 10 minutes for some reason const DBMigrate = await eval('imp' + 'ort("db-migrate")'); const dbmigrate = DBMigrate.getInstance(true); await dbmigrate.up(); } + } else if (process.env.NEXT_RUNTIME === 'edge') { + (globalThis as any).bcrypt = {}; + (globalThis as any).mysql2 = {}; } } diff --git a/src/routes.ts b/src/routes.ts index cc0be09..ca275e5 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -28,6 +28,9 @@ export const MAIN_ROUTES: Route = { routes: [{ url: '/dashboard', name: 'Overview' + }, { + url: '/team', + name: 'Teams' }, { url: '/arcade', name: 'Arcades'