From 335f1821cdcf4f7523de7daa99dd9eaca862d587 Mon Sep 17 00:00:00 2001 From: jd Date: Fri, 20 Feb 2026 23:51:16 +0000 Subject: [PATCH] Implemented invitation logic. Implemented play list method. Mild refactoring. --- Dockerfile | 2 - package.json | 6 +- scripts/dbCreate.sql | 754 +++++++++++++++++++++++++++--- src/emails/invite.tsx | 77 +++ src/endpoints/auth.ts | 7 +- src/endpoints/invite.ts | 37 ++ src/endpoints/player.ts | 9 + src/endpoints/user.ts | 6 +- src/index.ts | 2 + src/orm/claims.ts | 11 +- src/orm/games.ts | 24 +- src/orm/invites.ts | 133 ++++++ src/orm/orm.ts | 2 + src/orm/players.ts | 78 ++-- src/orm/user.ts | 65 +-- src/routes/invite.ts | 12 + src/routes/player.ts | 3 + src/routes/user.ts | 8 +- src/utilities/claimDefinitions.ts | 1 + src/utilities/errors.ts | 6 + src/utilities/guard.ts | 3 +- src/utilities/helpers.ts | 17 + src/utilities/requestModels.ts | 20 +- tsconfig.json | 1 + 24 files changed, 1118 insertions(+), 166 deletions(-) create mode 100644 src/emails/invite.tsx create mode 100644 src/endpoints/invite.ts create mode 100644 src/orm/invites.ts create mode 100644 src/routes/invite.ts diff --git a/Dockerfile b/Dockerfile index c9c4cbc..0d70044 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,8 +24,6 @@ COPY ./package.json ./package.json # copy production dependencies and source code into final image FROM base AS release -ENV DATABASE_URL=postgres://admin:iiyama12@192.168.1.166:5432/bgApp -ENV JWT_SECRET_KEY=MySecret COPY --from=install /temp/prod/node_modules node_modules COPY --from=prerelease /usr/src/app/index.ts . COPY --from=prerelease /usr/src/app/utilities/ ./utilities diff --git a/package.json b/package.json index d220521..66053eb 100644 --- a/package.json +++ b/package.json @@ -10,13 +10,17 @@ }, "private": true, "dependencies": { + "@react-email/render": "^2.0.4", "@types/jsonwebtoken": "^9.0.10", "@types/lodash": "^4.17.23", + "@types/react": "^19.2.14", "argon2": "^0.44.0", "hashids": "^2.3.0", "jsonwebtoken": "^9.0.3", "lodash": "^4.17.23", - "reflect-metadata": "^0.2.2" + "react": "^19.2.4", + "reflect-metadata": "^0.2.2", + "resend": "^6.9.2" }, "devDependencies": { "@types/bun": "^1.3.9" diff --git a/scripts/dbCreate.sql b/scripts/dbCreate.sql index f9f0b99..ddac662 100644 --- a/scripts/dbCreate.sql +++ b/scripts/dbCreate.sql @@ -2,10 +2,12 @@ -- PostgreSQL database dump -- +\restrict V5y7Wgbswasgm7UnGlx0qTGOk52yHGHAWgST3HXveDIktl8Dn0CpNmczwHt70e5 + -- Dumped from database version 18.1 (Debian 18.1-1.pgdg13+2) -- Dumped by pg_dump version 18.1 --- Started on 2026-02-18 20:07:03 GMT +-- Started on 2026-02-20 14:19:31 GMT SET statement_timeout = 0; SET lock_timeout = 0; @@ -28,7 +30,7 @@ CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA public; -- --- TOC entry 3606 (class 0 OID 0) +-- TOC entry 3624 (class 0 OID 0) -- Dependencies: 2 -- Name: EXTENSION pg_trgm; Type: COMMENT; Schema: -; Owner: -- @@ -53,6 +55,44 @@ CREATE TABLE public.circle_comments ( ALTER TABLE public.circle_comments OWNER TO admin; +-- +-- TOC entry 245 (class 1259 OID 20821) +-- Name: circle_invites; Type: TABLE; Schema: public; Owner: admin +-- + +CREATE TABLE public.circle_invites ( + id bigint NOT NULL, + invited_user_id bigint NOT NULL, + invited_by_user_id bigint NOT NULL +); + + +ALTER TABLE public.circle_invites OWNER TO admin; + +-- +-- TOC entry 244 (class 1259 OID 20820) +-- Name: circle_invites_id_seq; Type: SEQUENCE; Schema: public; Owner: admin +-- + +CREATE SEQUENCE public.circle_invites_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE public.circle_invites_id_seq OWNER TO admin; + +-- +-- TOC entry 3658 (class 0 OID 0) +-- Dependencies: 244 +-- Name: circle_invites_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: admin +-- + +ALTER SEQUENCE public.circle_invites_id_seq OWNED BY public.circle_invites.id; + + -- -- TOC entry 230 (class 1259 OID 19837) -- Name: circles; Type: TABLE; Schema: public; Owner: admin @@ -86,7 +126,7 @@ CREATE SEQUENCE public.circles_id_seq ALTER SEQUENCE public.circles_id_seq OWNER TO admin; -- --- TOC entry 3607 (class 0 OID 0) +-- TOC entry 3661 (class 0 OID 0) -- Dependencies: 229 -- Name: circles_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: admin -- @@ -125,7 +165,7 @@ CREATE SEQUENCE public.claims_id_seq ALTER SEQUENCE public.claims_id_seq OWNER TO admin; -- --- TOC entry 3608 (class 0 OID 0) +-- TOC entry 3664 (class 0 OID 0) -- Dependencies: 224 -- Name: claims_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: admin -- @@ -176,7 +216,7 @@ CREATE SEQUENCE public.collections_id_seq ALTER SEQUENCE public.collections_id_seq OWNER TO admin; -- --- TOC entry 3609 (class 0 OID 0) +-- TOC entry 3668 (class 0 OID 0) -- Dependencies: 237 -- Name: collections_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: admin -- @@ -215,7 +255,7 @@ CREATE SEQUENCE public.comments_id_seq ALTER SEQUENCE public.comments_id_seq OWNER TO admin; -- --- TOC entry 3610 (class 0 OID 0) +-- TOC entry 3671 (class 0 OID 0) -- Dependencies: 240 -- Name: comments_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: admin -- @@ -254,7 +294,7 @@ CREATE SEQUENCE public.games_id_seq ALTER SEQUENCE public.games_id_seq OWNER TO admin; -- --- TOC entry 3611 (class 0 OID 0) +-- TOC entry 3674 (class 0 OID 0) -- Dependencies: 231 -- Name: games_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: admin -- @@ -320,7 +360,7 @@ CREATE SEQUENCE public.matches_id_seq ALTER SEQUENCE public.matches_id_seq OWNER TO admin; -- --- TOC entry 3612 (class 0 OID 0) +-- TOC entry 3679 (class 0 OID 0) -- Dependencies: 233 -- Name: matches_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: admin -- @@ -373,7 +413,7 @@ CREATE SEQUENCE public.players_id_seq ALTER SEQUENCE public.players_id_seq OWNER TO admin; -- --- TOC entry 3613 (class 0 OID 0) +-- TOC entry 3683 (class 0 OID 0) -- Dependencies: 227 -- Name: players_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: admin -- @@ -381,6 +421,48 @@ ALTER SEQUENCE public.players_id_seq OWNER TO admin; ALTER SEQUENCE public.players_id_seq OWNED BY public.players.id; +-- +-- TOC entry 247 (class 1259 OID 20841) +-- Name: userInvites; Type: TABLE; Schema: public; Owner: admin +-- + +CREATE TABLE public."userInvites" ( + id bigint NOT NULL, + invite_code character varying(6) NOT NULL, + email text NOT NULL, + player_id bigint NOT NULL, + invited_by_user_id bigint NOT NULL, + was_email_sent boolean DEFAULT false NOT NULL, + created_at timestamp(6) with time zone DEFAULT now() NOT NULL +); + + +ALTER TABLE public."userInvites" OWNER TO admin; + +-- +-- TOC entry 246 (class 1259 OID 20840) +-- Name: userInvites_id_seq; Type: SEQUENCE; Schema: public; Owner: admin +-- + +CREATE SEQUENCE public."userInvites_id_seq" + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE public."userInvites_id_seq" OWNER TO admin; + +-- +-- TOC entry 3685 (class 0 OID 0) +-- Dependencies: 246 +-- Name: userInvites_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: admin +-- + +ALTER SEQUENCE public."userInvites_id_seq" OWNED BY public."userInvites".id; + + -- -- TOC entry 226 (class 1259 OID 16439) -- Name: user_claims; Type: TABLE; Schema: public; Owner: admin @@ -401,7 +483,7 @@ ALTER TABLE public.user_claims OWNER TO admin; CREATE TABLE public.users ( id bigint NOT NULL, - username character varying(20) NOT NULL, + email character varying(100) CONSTRAINT users_username_not_null NOT NULL, pass_hash text NOT NULL, is_active boolean DEFAULT true CONSTRAINT users_active_not_null NOT NULL, is_admin boolean DEFAULT false NOT NULL, @@ -431,7 +513,7 @@ CREATE SEQUENCE public.users_id_seq ALTER SEQUENCE public.users_id_seq OWNER TO admin; -- --- TOC entry 3614 (class 0 OID 0) +-- TOC entry 3688 (class 0 OID 0) -- Dependencies: 220 -- Name: users_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: admin -- @@ -455,7 +537,7 @@ CREATE SEQUENCE public.users_pass_hash_seq ALTER SEQUENCE public.users_pass_hash_seq OWNER TO admin; -- --- TOC entry 3615 (class 0 OID 0) +-- TOC entry 3690 (class 0 OID 0) -- Dependencies: 222 -- Name: users_pass_hash_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: admin -- @@ -479,16 +561,24 @@ CREATE SEQUENCE public.users_username_seq ALTER SEQUENCE public.users_username_seq OWNER TO admin; -- --- TOC entry 3616 (class 0 OID 0) +-- TOC entry 3692 (class 0 OID 0) -- Dependencies: 221 -- Name: users_username_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: admin -- -ALTER SEQUENCE public.users_username_seq OWNED BY public.users.username; +ALTER SEQUENCE public.users_username_seq OWNED BY public.users.email; -- --- TOC entry 3412 (class 2604 OID 19840) +-- TOC entry 3428 (class 2604 OID 20824) +-- Name: circle_invites id; Type: DEFAULT; Schema: public; Owner: admin +-- + +ALTER TABLE ONLY public.circle_invites ALTER COLUMN id SET DEFAULT nextval('public.circle_invites_id_seq'::regclass); + + +-- +-- TOC entry 3420 (class 2604 OID 19840) -- Name: circles id; Type: DEFAULT; Schema: public; Owner: admin -- @@ -496,7 +586,7 @@ ALTER TABLE ONLY public.circles ALTER COLUMN id SET DEFAULT nextval('public.circ -- --- TOC entry 3406 (class 2604 OID 20726) +-- TOC entry 3414 (class 2604 OID 20726) -- Name: claims id; Type: DEFAULT; Schema: public; Owner: admin -- @@ -504,7 +594,7 @@ ALTER TABLE ONLY public.claims ALTER COLUMN id SET DEFAULT nextval('public.claim -- --- TOC entry 3417 (class 2604 OID 19908) +-- TOC entry 3425 (class 2604 OID 19908) -- Name: collections id; Type: DEFAULT; Schema: public; Owner: admin -- @@ -512,7 +602,7 @@ ALTER TABLE ONLY public.collections ALTER COLUMN id SET DEFAULT nextval('public. -- --- TOC entry 3418 (class 2604 OID 19943) +-- TOC entry 3426 (class 2604 OID 19943) -- Name: comments id; Type: DEFAULT; Schema: public; Owner: admin -- @@ -520,7 +610,7 @@ ALTER TABLE ONLY public.comments ALTER COLUMN id SET DEFAULT nextval('public.com -- --- TOC entry 3415 (class 2604 OID 19851) +-- TOC entry 3423 (class 2604 OID 19851) -- Name: games id; Type: DEFAULT; Schema: public; Owner: admin -- @@ -528,7 +618,7 @@ ALTER TABLE ONLY public.games ALTER COLUMN id SET DEFAULT nextval('public.games_ -- --- TOC entry 3416 (class 2604 OID 19862) +-- TOC entry 3424 (class 2604 OID 19862) -- Name: matches id; Type: DEFAULT; Schema: public; Owner: admin -- @@ -536,7 +626,7 @@ ALTER TABLE ONLY public.matches ALTER COLUMN id SET DEFAULT nextval('public.matc -- --- TOC entry 3408 (class 2604 OID 19812) +-- TOC entry 3416 (class 2604 OID 19812) -- Name: players id; Type: DEFAULT; Schema: public; Owner: admin -- @@ -544,7 +634,15 @@ ALTER TABLE ONLY public.players ALTER COLUMN id SET DEFAULT nextval('public.play -- --- TOC entry 3398 (class 2604 OID 16395) +-- TOC entry 3429 (class 2604 OID 20844) +-- Name: userInvites id; Type: DEFAULT; Schema: public; Owner: admin +-- + +ALTER TABLE ONLY public."userInvites" ALTER COLUMN id SET DEFAULT nextval('public."userInvites_id_seq"'::regclass); + + +-- +-- TOC entry 3408 (class 2604 OID 16395) -- Name: users id; Type: DEFAULT; Schema: public; Owner: admin -- @@ -552,23 +650,16 @@ ALTER TABLE ONLY public.users ALTER COLUMN id SET DEFAULT nextval('public.users_ -- --- TOC entry 3399 (class 2604 OID 16405) --- Name: users username; Type: DEFAULT; Schema: public; Owner: admin +-- TOC entry 3449 (class 2606 OID 20829) +-- Name: circle_invites circle_invites_pkey; Type: CONSTRAINT; Schema: public; Owner: admin -- -ALTER TABLE ONLY public.users ALTER COLUMN username SET DEFAULT nextval('public.users_username_seq'::regclass); +ALTER TABLE ONLY public.circle_invites + ADD CONSTRAINT circle_invites_pkey PRIMARY KEY (id); -- --- TOC entry 3400 (class 2604 OID 16411) --- Name: users pass_hash; Type: DEFAULT; Schema: public; Owner: admin --- - -ALTER TABLE ONLY public.users ALTER COLUMN pass_hash SET DEFAULT nextval('public.users_pass_hash_seq'::regclass); - - --- --- TOC entry 3427 (class 2606 OID 19846) +-- TOC entry 3439 (class 2606 OID 19846) -- Name: circles circles_pkey; Type: CONSTRAINT; Schema: public; Owner: admin -- @@ -577,7 +668,7 @@ ALTER TABLE ONLY public.circles -- --- TOC entry 3423 (class 2606 OID 20728) +-- TOC entry 3435 (class 2606 OID 20728) -- Name: claims claims_pkey; Type: CONSTRAINT; Schema: public; Owner: admin -- @@ -586,7 +677,7 @@ ALTER TABLE ONLY public.claims -- --- TOC entry 3433 (class 2606 OID 19913) +-- TOC entry 3445 (class 2606 OID 19913) -- Name: collections collections_pkey; Type: CONSTRAINT; Schema: public; Owner: admin -- @@ -595,7 +686,7 @@ ALTER TABLE ONLY public.collections -- --- TOC entry 3435 (class 2606 OID 19952) +-- TOC entry 3447 (class 2606 OID 19952) -- Name: comments comments_pkey; Type: CONSTRAINT; Schema: public; Owner: admin -- @@ -604,7 +695,7 @@ ALTER TABLE ONLY public.comments -- --- TOC entry 3429 (class 2606 OID 19857) +-- TOC entry 3441 (class 2606 OID 19857) -- Name: games games_pkey; Type: CONSTRAINT; Schema: public; Owner: admin -- @@ -613,7 +704,7 @@ ALTER TABLE ONLY public.games -- --- TOC entry 3431 (class 2606 OID 19866) +-- TOC entry 3443 (class 2606 OID 19866) -- Name: matches matches_pkey; Type: CONSTRAINT; Schema: public; Owner: admin -- @@ -622,7 +713,7 @@ ALTER TABLE ONLY public.matches -- --- TOC entry 3425 (class 2606 OID 19824) +-- TOC entry 3437 (class 2606 OID 19824) -- Name: players players_pkey; Type: CONSTRAINT; Schema: public; Owner: admin -- @@ -631,7 +722,16 @@ ALTER TABLE ONLY public.players -- --- TOC entry 3421 (class 2606 OID 16403) +-- TOC entry 3451 (class 2606 OID 20857) +-- Name: userInvites userInvites_pkey; Type: CONSTRAINT; Schema: public; Owner: admin +-- + +ALTER TABLE ONLY public."userInvites" + ADD CONSTRAINT "userInvites_pkey" PRIMARY KEY (id); + + +-- +-- TOC entry 3433 (class 2606 OID 16403) -- Name: users users_pkey; Type: CONSTRAINT; Schema: public; Owner: admin -- @@ -640,7 +740,7 @@ ALTER TABLE ONLY public.users -- --- TOC entry 3452 (class 2606 OID 19979) +-- TOC entry 3468 (class 2606 OID 19979) -- Name: circle_comments circle_comments_circles_fkey; Type: FK CONSTRAINT; Schema: public; Owner: admin -- @@ -649,7 +749,7 @@ ALTER TABLE ONLY public.circle_comments -- --- TOC entry 3453 (class 2606 OID 19984) +-- TOC entry 3469 (class 2606 OID 19984) -- Name: circle_comments circle_comments_comment_fkey; Type: FK CONSTRAINT; Schema: public; Owner: admin -- @@ -658,7 +758,25 @@ ALTER TABLE ONLY public.circle_comments -- --- TOC entry 3439 (class 2606 OID 20708) +-- TOC entry 3470 (class 2606 OID 20835) +-- Name: circle_invites circle_invites_invited_by_user_fkey; Type: FK CONSTRAINT; Schema: public; Owner: admin +-- + +ALTER TABLE ONLY public.circle_invites + ADD CONSTRAINT circle_invites_invited_by_user_fkey FOREIGN KEY (invited_by_user_id) REFERENCES public.users(id); + + +-- +-- TOC entry 3471 (class 2606 OID 20830) +-- Name: circle_invites circle_invites_invited_user_fkey; Type: FK CONSTRAINT; Schema: public; Owner: admin +-- + +ALTER TABLE ONLY public.circle_invites + ADD CONSTRAINT circle_invites_invited_user_fkey FOREIGN KEY (invited_user_id) REFERENCES public.users(id); + + +-- +-- TOC entry 3455 (class 2606 OID 20708) -- Name: circles circles_users_fkey; Type: FK CONSTRAINT; Schema: public; Owner: admin -- @@ -667,7 +785,7 @@ ALTER TABLE ONLY public.circles -- --- TOC entry 3447 (class 2606 OID 19924) +-- TOC entry 3463 (class 2606 OID 19924) -- Name: collection_games collection_games_collections_fkey; Type: FK CONSTRAINT; Schema: public; Owner: admin -- @@ -676,7 +794,7 @@ ALTER TABLE ONLY public.collection_games -- --- TOC entry 3448 (class 2606 OID 19929) +-- TOC entry 3464 (class 2606 OID 19929) -- Name: collection_games collection_games_games_fkey; Type: FK CONSTRAINT; Schema: public; Owner: admin -- @@ -685,7 +803,7 @@ ALTER TABLE ONLY public.collection_games -- --- TOC entry 3446 (class 2606 OID 19914) +-- TOC entry 3462 (class 2606 OID 19914) -- Name: collections collections_users_fkey; Type: FK CONSTRAINT; Schema: public; Owner: admin -- @@ -694,7 +812,7 @@ ALTER TABLE ONLY public.collections -- --- TOC entry 3449 (class 2606 OID 19953) +-- TOC entry 3465 (class 2606 OID 19953) -- Name: comments comments_users_fkey; Type: FK CONSTRAINT; Schema: public; Owner: admin -- @@ -703,7 +821,7 @@ ALTER TABLE ONLY public.comments -- --- TOC entry 3450 (class 2606 OID 19969) +-- TOC entry 3466 (class 2606 OID 19969) -- Name: match_comments match_comments_comments_fkey; Type: FK CONSTRAINT; Schema: public; Owner: admin -- @@ -712,7 +830,7 @@ ALTER TABLE ONLY public.match_comments -- --- TOC entry 3451 (class 2606 OID 19964) +-- TOC entry 3467 (class 2606 OID 19964) -- Name: match_comments match_comments_matches_fkey; Type: FK CONSTRAINT; Schema: public; Owner: admin -- @@ -721,7 +839,7 @@ ALTER TABLE ONLY public.match_comments -- --- TOC entry 3442 (class 2606 OID 19884) +-- TOC entry 3458 (class 2606 OID 19884) -- Name: match_players match_players_matches_fkey; Type: FK CONSTRAINT; Schema: public; Owner: admin -- @@ -730,7 +848,7 @@ ALTER TABLE ONLY public.match_players -- --- TOC entry 3443 (class 2606 OID 19879) +-- TOC entry 3459 (class 2606 OID 19879) -- Name: match_players match_players_players_fkey; Type: FK CONSTRAINT; Schema: public; Owner: admin -- @@ -739,7 +857,7 @@ ALTER TABLE ONLY public.match_players -- --- TOC entry 3440 (class 2606 OID 19867) +-- TOC entry 3456 (class 2606 OID 19867) -- Name: matches matches_games_fkey; Type: FK CONSTRAINT; Schema: public; Owner: admin -- @@ -748,7 +866,7 @@ ALTER TABLE ONLY public.matches -- --- TOC entry 3441 (class 2606 OID 20721) +-- TOC entry 3457 (class 2606 OID 20721) -- Name: matches matches_users_fkey; Type: FK CONSTRAINT; Schema: public; Owner: admin -- @@ -757,7 +875,7 @@ ALTER TABLE ONLY public.matches -- --- TOC entry 3444 (class 2606 OID 19899) +-- TOC entry 3460 (class 2606 OID 19899) -- Name: player_circles player_circles_circles_fkey; Type: FK CONSTRAINT; Schema: public; Owner: admin -- @@ -766,7 +884,7 @@ ALTER TABLE ONLY public.player_circles -- --- TOC entry 3445 (class 2606 OID 19894) +-- TOC entry 3461 (class 2606 OID 19894) -- Name: player_circles player_circles_players_fkey; Type: FK CONSTRAINT; Schema: public; Owner: admin -- @@ -775,7 +893,7 @@ ALTER TABLE ONLY public.player_circles -- --- TOC entry 3437 (class 2606 OID 20730) +-- TOC entry 3453 (class 2606 OID 20730) -- Name: user_claims user_claims_claimid_fkey; Type: FK CONSTRAINT; Schema: public; Owner: admin -- @@ -784,7 +902,7 @@ ALTER TABLE ONLY public.user_claims -- --- TOC entry 3438 (class 2606 OID 16442) +-- TOC entry 3454 (class 2606 OID 16442) -- Name: user_claims user_claims_userid_fkey; Type: FK CONSTRAINT; Schema: public; Owner: admin -- @@ -793,7 +911,7 @@ ALTER TABLE ONLY public.user_claims -- --- TOC entry 3436 (class 2606 OID 19830) +-- TOC entry 3452 (class 2606 OID 19830) -- Name: users users_players_fkey; Type: FK CONSTRAINT; Schema: public; Owner: admin -- @@ -801,8 +919,524 @@ ALTER TABLE ONLY public.users ADD CONSTRAINT users_players_fkey FOREIGN KEY (player_id) REFERENCES public.players(id) NOT VALID; --- Completed on 2026-02-18 20:07:03 GMT +-- +-- TOC entry 3625 (class 0 OID 0) +-- Dependencies: 257 +-- Name: FUNCTION gtrgm_in(cstring); Type: ACL; Schema: public; Owner: admin +-- + +GRANT ALL ON FUNCTION public.gtrgm_in(cstring) TO "ApiUser"; + + +-- +-- TOC entry 3626 (class 0 OID 0) +-- Dependencies: 258 +-- Name: FUNCTION gtrgm_out(public.gtrgm); Type: ACL; Schema: public; Owner: admin +-- + +GRANT ALL ON FUNCTION public.gtrgm_out(public.gtrgm) TO "ApiUser"; + + +-- +-- TOC entry 3627 (class 0 OID 0) +-- Dependencies: 249 +-- Name: FUNCTION gin_extract_query_trgm(text, internal, smallint, internal, internal, internal, internal); Type: ACL; Schema: public; Owner: admin +-- + +GRANT ALL ON FUNCTION public.gin_extract_query_trgm(text, internal, smallint, internal, internal, internal, internal) TO "ApiUser"; + + +-- +-- TOC entry 3628 (class 0 OID 0) +-- Dependencies: 250 +-- Name: FUNCTION gin_extract_value_trgm(text, internal); Type: ACL; Schema: public; Owner: admin +-- + +GRANT ALL ON FUNCTION public.gin_extract_value_trgm(text, internal) TO "ApiUser"; + + +-- +-- TOC entry 3629 (class 0 OID 0) +-- Dependencies: 251 +-- Name: FUNCTION gin_trgm_consistent(internal, smallint, text, integer, internal, internal, internal, internal); Type: ACL; Schema: public; Owner: admin +-- + +GRANT ALL ON FUNCTION public.gin_trgm_consistent(internal, smallint, text, integer, internal, internal, internal, internal) TO "ApiUser"; + + +-- +-- TOC entry 3630 (class 0 OID 0) +-- Dependencies: 252 +-- Name: FUNCTION gin_trgm_triconsistent(internal, smallint, text, integer, internal, internal, internal); Type: ACL; Schema: public; Owner: admin +-- + +GRANT ALL ON FUNCTION public.gin_trgm_triconsistent(internal, smallint, text, integer, internal, internal, internal) TO "ApiUser"; + + +-- +-- TOC entry 3631 (class 0 OID 0) +-- Dependencies: 253 +-- Name: FUNCTION gtrgm_compress(internal); Type: ACL; Schema: public; Owner: admin +-- + +GRANT ALL ON FUNCTION public.gtrgm_compress(internal) TO "ApiUser"; + + +-- +-- TOC entry 3632 (class 0 OID 0) +-- Dependencies: 254 +-- Name: FUNCTION gtrgm_consistent(internal, text, smallint, oid, internal); Type: ACL; Schema: public; Owner: admin +-- + +GRANT ALL ON FUNCTION public.gtrgm_consistent(internal, text, smallint, oid, internal) TO "ApiUser"; + + +-- +-- TOC entry 3633 (class 0 OID 0) +-- Dependencies: 255 +-- Name: FUNCTION gtrgm_decompress(internal); Type: ACL; Schema: public; Owner: admin +-- + +GRANT ALL ON FUNCTION public.gtrgm_decompress(internal) TO "ApiUser"; + + +-- +-- TOC entry 3634 (class 0 OID 0) +-- Dependencies: 256 +-- Name: FUNCTION gtrgm_distance(internal, text, smallint, oid, internal); Type: ACL; Schema: public; Owner: admin +-- + +GRANT ALL ON FUNCTION public.gtrgm_distance(internal, text, smallint, oid, internal) TO "ApiUser"; + + +-- +-- TOC entry 3635 (class 0 OID 0) +-- Dependencies: 248 +-- Name: FUNCTION gtrgm_options(internal); Type: ACL; Schema: public; Owner: admin +-- + +GRANT ALL ON FUNCTION public.gtrgm_options(internal) TO "ApiUser"; + + +-- +-- TOC entry 3636 (class 0 OID 0) +-- Dependencies: 259 +-- Name: FUNCTION gtrgm_penalty(internal, internal, internal); Type: ACL; Schema: public; Owner: admin +-- + +GRANT ALL ON FUNCTION public.gtrgm_penalty(internal, internal, internal) TO "ApiUser"; + + +-- +-- TOC entry 3637 (class 0 OID 0) +-- Dependencies: 260 +-- Name: FUNCTION gtrgm_picksplit(internal, internal); Type: ACL; Schema: public; Owner: admin +-- + +GRANT ALL ON FUNCTION public.gtrgm_picksplit(internal, internal) TO "ApiUser"; + + +-- +-- TOC entry 3638 (class 0 OID 0) +-- Dependencies: 261 +-- Name: FUNCTION gtrgm_same(public.gtrgm, public.gtrgm, internal); Type: ACL; Schema: public; Owner: admin +-- + +GRANT ALL ON FUNCTION public.gtrgm_same(public.gtrgm, public.gtrgm, internal) TO "ApiUser"; + + +-- +-- TOC entry 3639 (class 0 OID 0) +-- Dependencies: 262 +-- Name: FUNCTION gtrgm_union(internal, internal); Type: ACL; Schema: public; Owner: admin +-- + +GRANT ALL ON FUNCTION public.gtrgm_union(internal, internal) TO "ApiUser"; + + +-- +-- TOC entry 3640 (class 0 OID 0) +-- Dependencies: 263 +-- Name: FUNCTION set_limit(real); Type: ACL; Schema: public; Owner: admin +-- + +GRANT ALL ON FUNCTION public.set_limit(real) TO "ApiUser"; + + +-- +-- TOC entry 3641 (class 0 OID 0) +-- Dependencies: 264 +-- Name: FUNCTION show_limit(); Type: ACL; Schema: public; Owner: admin +-- + +GRANT ALL ON FUNCTION public.show_limit() TO "ApiUser"; + + +-- +-- TOC entry 3642 (class 0 OID 0) +-- Dependencies: 265 +-- Name: FUNCTION show_trgm(text); Type: ACL; Schema: public; Owner: admin +-- + +GRANT ALL ON FUNCTION public.show_trgm(text) TO "ApiUser"; + + +-- +-- TOC entry 3643 (class 0 OID 0) +-- Dependencies: 266 +-- Name: FUNCTION similarity(text, text); Type: ACL; Schema: public; Owner: admin +-- + +GRANT ALL ON FUNCTION public.similarity(text, text) TO "ApiUser"; + + +-- +-- TOC entry 3644 (class 0 OID 0) +-- Dependencies: 267 +-- Name: FUNCTION similarity_dist(text, text); Type: ACL; Schema: public; Owner: admin +-- + +GRANT ALL ON FUNCTION public.similarity_dist(text, text) TO "ApiUser"; + + +-- +-- TOC entry 3645 (class 0 OID 0) +-- Dependencies: 268 +-- Name: FUNCTION similarity_op(text, text); Type: ACL; Schema: public; Owner: admin +-- + +GRANT ALL ON FUNCTION public.similarity_op(text, text) TO "ApiUser"; + + +-- +-- TOC entry 3646 (class 0 OID 0) +-- Dependencies: 269 +-- Name: FUNCTION strict_word_similarity(text, text); Type: ACL; Schema: public; Owner: admin +-- + +GRANT ALL ON FUNCTION public.strict_word_similarity(text, text) TO "ApiUser"; + + +-- +-- TOC entry 3647 (class 0 OID 0) +-- Dependencies: 270 +-- Name: FUNCTION strict_word_similarity_commutator_op(text, text); Type: ACL; Schema: public; Owner: admin +-- + +GRANT ALL ON FUNCTION public.strict_word_similarity_commutator_op(text, text) TO "ApiUser"; + + +-- +-- TOC entry 3648 (class 0 OID 0) +-- Dependencies: 271 +-- Name: FUNCTION strict_word_similarity_dist_commutator_op(text, text); Type: ACL; Schema: public; Owner: admin +-- + +GRANT ALL ON FUNCTION public.strict_word_similarity_dist_commutator_op(text, text) TO "ApiUser"; + + +-- +-- TOC entry 3649 (class 0 OID 0) +-- Dependencies: 272 +-- Name: FUNCTION strict_word_similarity_dist_op(text, text); Type: ACL; Schema: public; Owner: admin +-- + +GRANT ALL ON FUNCTION public.strict_word_similarity_dist_op(text, text) TO "ApiUser"; + + +-- +-- TOC entry 3650 (class 0 OID 0) +-- Dependencies: 273 +-- Name: FUNCTION strict_word_similarity_op(text, text); Type: ACL; Schema: public; Owner: admin +-- + +GRANT ALL ON FUNCTION public.strict_word_similarity_op(text, text) TO "ApiUser"; + + +-- +-- TOC entry 3651 (class 0 OID 0) +-- Dependencies: 274 +-- Name: FUNCTION word_similarity(text, text); Type: ACL; Schema: public; Owner: admin +-- + +GRANT ALL ON FUNCTION public.word_similarity(text, text) TO "ApiUser"; + + +-- +-- TOC entry 3652 (class 0 OID 0) +-- Dependencies: 275 +-- Name: FUNCTION word_similarity_commutator_op(text, text); Type: ACL; Schema: public; Owner: admin +-- + +GRANT ALL ON FUNCTION public.word_similarity_commutator_op(text, text) TO "ApiUser"; + + +-- +-- TOC entry 3653 (class 0 OID 0) +-- Dependencies: 276 +-- Name: FUNCTION word_similarity_dist_commutator_op(text, text); Type: ACL; Schema: public; Owner: admin +-- + +GRANT ALL ON FUNCTION public.word_similarity_dist_commutator_op(text, text) TO "ApiUser"; + + +-- +-- TOC entry 3654 (class 0 OID 0) +-- Dependencies: 277 +-- Name: FUNCTION word_similarity_dist_op(text, text); Type: ACL; Schema: public; Owner: admin +-- + +GRANT ALL ON FUNCTION public.word_similarity_dist_op(text, text) TO "ApiUser"; + + +-- +-- TOC entry 3655 (class 0 OID 0) +-- Dependencies: 278 +-- Name: FUNCTION word_similarity_op(text, text); Type: ACL; Schema: public; Owner: admin +-- + +GRANT ALL ON FUNCTION public.word_similarity_op(text, text) TO "ApiUser"; + + +-- +-- TOC entry 3656 (class 0 OID 0) +-- Dependencies: 243 +-- Name: TABLE circle_comments; Type: ACL; Schema: public; Owner: admin +-- + +GRANT SELECT,INSERT,DELETE,TRIGGER,UPDATE ON TABLE public.circle_comments TO "ApiUser"; + + +-- +-- TOC entry 3657 (class 0 OID 0) +-- Dependencies: 245 +-- Name: TABLE circle_invites; Type: ACL; Schema: public; Owner: admin +-- + +GRANT SELECT,INSERT,DELETE,TRIGGER,UPDATE ON TABLE public.circle_invites TO "ApiUser"; + + +-- +-- TOC entry 3659 (class 0 OID 0) +-- Dependencies: 244 +-- Name: SEQUENCE circle_invites_id_seq; Type: ACL; Schema: public; Owner: admin +-- + +GRANT ALL ON SEQUENCE public.circle_invites_id_seq TO "ApiUser"; + + +-- +-- TOC entry 3660 (class 0 OID 0) +-- Dependencies: 230 +-- Name: TABLE circles; Type: ACL; Schema: public; Owner: admin +-- + +GRANT SELECT,INSERT,DELETE,TRIGGER,UPDATE ON TABLE public.circles TO "ApiUser"; + + +-- +-- TOC entry 3662 (class 0 OID 0) +-- Dependencies: 229 +-- Name: SEQUENCE circles_id_seq; Type: ACL; Schema: public; Owner: admin +-- + +GRANT ALL ON SEQUENCE public.circles_id_seq TO "ApiUser"; + + +-- +-- TOC entry 3663 (class 0 OID 0) +-- Dependencies: 225 +-- Name: TABLE claims; Type: ACL; Schema: public; Owner: admin +-- + +GRANT SELECT,INSERT,DELETE,TRIGGER,UPDATE ON TABLE public.claims TO "ApiUser"; + + +-- +-- TOC entry 3665 (class 0 OID 0) +-- Dependencies: 224 +-- Name: SEQUENCE claims_id_seq; Type: ACL; Schema: public; Owner: admin +-- + +GRANT ALL ON SEQUENCE public.claims_id_seq TO "ApiUser"; + + +-- +-- TOC entry 3666 (class 0 OID 0) +-- Dependencies: 239 +-- Name: TABLE collection_games; Type: ACL; Schema: public; Owner: admin +-- + +GRANT SELECT,INSERT,DELETE,TRIGGER,UPDATE ON TABLE public.collection_games TO "ApiUser"; + + +-- +-- TOC entry 3667 (class 0 OID 0) +-- Dependencies: 238 +-- Name: TABLE collections; Type: ACL; Schema: public; Owner: admin +-- + +GRANT SELECT,INSERT,DELETE,TRIGGER,UPDATE ON TABLE public.collections TO "ApiUser"; + + +-- +-- TOC entry 3669 (class 0 OID 0) +-- Dependencies: 237 +-- Name: SEQUENCE collections_id_seq; Type: ACL; Schema: public; Owner: admin +-- + +GRANT ALL ON SEQUENCE public.collections_id_seq TO "ApiUser"; + + +-- +-- TOC entry 3670 (class 0 OID 0) +-- Dependencies: 241 +-- Name: TABLE comments; Type: ACL; Schema: public; Owner: admin +-- + +GRANT SELECT,INSERT,DELETE,TRIGGER,UPDATE ON TABLE public.comments TO "ApiUser"; + + +-- +-- TOC entry 3672 (class 0 OID 0) +-- Dependencies: 240 +-- Name: SEQUENCE comments_id_seq; Type: ACL; Schema: public; Owner: admin +-- + +GRANT ALL ON SEQUENCE public.comments_id_seq TO "ApiUser"; + + +-- +-- TOC entry 3673 (class 0 OID 0) +-- Dependencies: 232 +-- Name: TABLE games; Type: ACL; Schema: public; Owner: admin +-- + +GRANT SELECT,INSERT,DELETE,TRIGGER,UPDATE ON TABLE public.games TO "ApiUser"; + + +-- +-- TOC entry 3675 (class 0 OID 0) +-- Dependencies: 231 +-- Name: SEQUENCE games_id_seq; Type: ACL; Schema: public; Owner: admin +-- + +GRANT ALL ON SEQUENCE public.games_id_seq TO "ApiUser"; + + +-- +-- TOC entry 3676 (class 0 OID 0) +-- Dependencies: 242 +-- Name: TABLE match_comments; Type: ACL; Schema: public; Owner: admin +-- + +GRANT SELECT,INSERT,DELETE,TRIGGER,UPDATE ON TABLE public.match_comments TO "ApiUser"; + + +-- +-- TOC entry 3677 (class 0 OID 0) +-- Dependencies: 235 +-- Name: TABLE match_players; Type: ACL; Schema: public; Owner: admin +-- + +GRANT SELECT,INSERT,DELETE,TRIGGER,UPDATE ON TABLE public.match_players TO "ApiUser"; + + +-- +-- TOC entry 3678 (class 0 OID 0) +-- Dependencies: 234 +-- Name: TABLE matches; Type: ACL; Schema: public; Owner: admin +-- + +GRANT SELECT,INSERT,DELETE,TRIGGER,UPDATE ON TABLE public.matches TO "ApiUser"; + + +-- +-- TOC entry 3680 (class 0 OID 0) +-- Dependencies: 233 +-- Name: SEQUENCE matches_id_seq; Type: ACL; Schema: public; Owner: admin +-- + +GRANT ALL ON SEQUENCE public.matches_id_seq TO "ApiUser"; + + +-- +-- TOC entry 3681 (class 0 OID 0) +-- Dependencies: 236 +-- Name: TABLE player_circles; Type: ACL; Schema: public; Owner: admin +-- + +GRANT SELECT,INSERT,DELETE,TRIGGER,UPDATE ON TABLE public.player_circles TO "ApiUser"; + + +-- +-- TOC entry 3682 (class 0 OID 0) +-- Dependencies: 228 +-- Name: TABLE players; Type: ACL; Schema: public; Owner: admin +-- + +GRANT SELECT,INSERT,DELETE,TRIGGER,UPDATE ON TABLE public.players TO "ApiUser"; + + +-- +-- TOC entry 3684 (class 0 OID 0) +-- Dependencies: 227 +-- Name: SEQUENCE players_id_seq; Type: ACL; Schema: public; Owner: admin +-- + +GRANT ALL ON SEQUENCE public.players_id_seq TO "ApiUser"; + + +-- +-- TOC entry 3686 (class 0 OID 0) +-- Dependencies: 226 +-- Name: TABLE user_claims; Type: ACL; Schema: public; Owner: admin +-- + +GRANT SELECT,INSERT,DELETE,TRIGGER,UPDATE ON TABLE public.user_claims TO "ApiUser"; + + +-- +-- TOC entry 3687 (class 0 OID 0) +-- Dependencies: 223 +-- Name: TABLE users; Type: ACL; Schema: public; Owner: admin +-- + +GRANT SELECT,INSERT,DELETE,TRIGGER,UPDATE ON TABLE public.users TO "ApiUser"; + + +-- +-- TOC entry 3689 (class 0 OID 0) +-- Dependencies: 220 +-- Name: SEQUENCE users_id_seq; Type: ACL; Schema: public; Owner: admin +-- + +GRANT ALL ON SEQUENCE public.users_id_seq TO "ApiUser"; + + +-- +-- TOC entry 3691 (class 0 OID 0) +-- Dependencies: 222 +-- Name: SEQUENCE users_pass_hash_seq; Type: ACL; Schema: public; Owner: admin +-- + +GRANT ALL ON SEQUENCE public.users_pass_hash_seq TO "ApiUser"; + + +-- +-- TOC entry 3693 (class 0 OID 0) +-- Dependencies: 221 +-- Name: SEQUENCE users_username_seq; Type: ACL; Schema: public; Owner: admin +-- + +GRANT ALL ON SEQUENCE public.users_username_seq TO "ApiUser"; + + +-- Completed on 2026-02-20 14:19:32 GMT -- -- PostgreSQL database dump complete -- + +\unrestrict V5y7Wgbswasgm7UnGlx0qTGOk52yHGHAWgST3HXveDIktl8Dn0CpNmczwHt70e5 + diff --git a/src/emails/invite.tsx b/src/emails/invite.tsx new file mode 100644 index 0000000..14df498 --- /dev/null +++ b/src/emails/invite.tsx @@ -0,0 +1,77 @@ +import * as React from 'react'; +import { brandColours } from '../utilities/helpers'; +import { size } from 'lodash'; + +interface InviteEmailProperties { + playerName: string; + inviteCode: string; +} + +export const InviteEmail = (props: InviteEmailProperties) => ( +
+ + + + +
+
+

You're in, {props.playerName}!

+

+ You've been invited to join {process.env.PRODUCT_NAME}, please click the button below to + finish signing up. +

+

+ + Join {process.env.PRODUCT_NAME} + +

+

+ If above button does not work, copy the link below into a new browser tab: +
+ {`${process.env.ROOT_URL}/invitation/${props.inviteCode}`} +

+
+
+
+); diff --git a/src/endpoints/auth.ts b/src/endpoints/auth.ts index e26dd60..73e67f6 100644 --- a/src/endpoints/auth.ts +++ b/src/endpoints/auth.ts @@ -10,7 +10,7 @@ async function login(request: UnwrappedRequest): Promise const verify: { userId: SecureId; refreshCount: string; - } | null = await orm.users.verifyCredentials(request.body.username, request.body.password); + } | null = await orm.users.verifyCredentials(request.body.email, request.body.password); if (!verify) { return new UnauthorizedResponse('Invalid credentials'); } @@ -32,6 +32,7 @@ async function login(request: UnwrappedRequest): Promise httpOnly: true, secure: true, maxAge: tokenLifeSpanInDays * 24 * 60 * 60, + path: '/api/auth/token' }); return new OkResponse(); } catch (error: any) { @@ -60,7 +61,9 @@ async function token(request: UnwrappedRequest): Promise { const claims: Claims | null = await orm.claims.getByUserId(refreshToken.u); - const token = jwt.sign({ ...claims }, process.env.JWT_SECRET_KEY as string, { expiresIn: '1h' }); + const token = jwt.sign({ ...claims }, process.env.JWT_SECRET_KEY as string, { + expiresIn: process.env.JWT_LIFESPAN as any, + }); return new OkResponse({ token }); } catch (error: any) { return new ErrorResponse(error as Error); diff --git a/src/endpoints/invite.ts b/src/endpoints/invite.ts new file mode 100644 index 0000000..824bec8 --- /dev/null +++ b/src/endpoints/invite.ts @@ -0,0 +1,37 @@ +import { orm } from '../orm/orm'; +import { UnwrappedRequest } from '../utilities/guard'; +import { CreatedResponse, ErrorResponse } from '../utilities/responseHelper'; +import { + AcceptInviteRequest, + InviteUserRequest, + SecureId, +} from '../utilities/requestModels'; + +async function create(request: UnwrappedRequest): Promise { + try { + const newUser = await orm.invites.create( + { + ...request.body, + playerId: SecureId.fromHash(request.body.playerId), + invitedByUserId: request.claims.userId as SecureId, + }, + ); + return new CreatedResponse(newUser); + } catch (error: any) { + return new ErrorResponse(error as Error); + } +} + +async function accept(request: UnwrappedRequest): Promise { + try { + const newUser = await orm.invites.accept(request.body); + return new CreatedResponse(newUser); + } catch (error: any) { + return new ErrorResponse(error as Error); + } +} + +export default { + create, + accept, +}; diff --git a/src/endpoints/player.ts b/src/endpoints/player.ts index 69acfd6..d97fc87 100644 --- a/src/endpoints/player.ts +++ b/src/endpoints/player.ts @@ -20,6 +20,14 @@ async function get(request: UnwrappedRequest): Promise { } } +async function list(request: UnwrappedRequest): Promise { + try { + return new OkResponse(await orm.players.list(request.claims)); + } catch (error: any) { + return new ErrorResponse(error as Error); + } +} + async function update(request: UnwrappedRequest): Promise { try { return new OkResponse( @@ -41,6 +49,7 @@ async function drop(request: UnwrappedRequest): Promise { export default { create, get, + list, update, drop, }; diff --git a/src/endpoints/user.ts b/src/endpoints/user.ts index fc40100..bc355b4 100644 --- a/src/endpoints/user.ts +++ b/src/endpoints/user.ts @@ -29,11 +29,7 @@ async function get(request: UnwrappedRequest): Promise { async function update(request: UnwrappedRequest): Promise { try { return new OkResponse( - await orm.users.update( - SecureId.fromHash(request.params.id), - request.body, - request.claims, - ), + await orm.users.update(SecureId.fromHash(request.params.id), request.body, request.claims), ); } catch (error: any) { return new ErrorResponse(error as Error); diff --git a/src/index.ts b/src/index.ts index 44859cc..ec78d99 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import user from './routes/user'; import player from './routes/player'; import game from './routes/game'; import { OkResponse } from './utilities/responseHelper'; +import invite from './routes/invite'; const server = Bun.serve({ routes: { @@ -10,6 +11,7 @@ const server = Bun.serve({ ...user, ...player, ...game, + ...invite, '/test': { GET: () => { return new OkResponse(); diff --git a/src/orm/claims.ts b/src/orm/claims.ts index a3bec1a..c732823 100644 --- a/src/orm/claims.ts +++ b/src/orm/claims.ts @@ -1,10 +1,17 @@ import { sql } from 'bun'; import { ClaimDefinition } from '../utilities/claimDefinitions'; +import { SecureId } from '../utilities/requestModels'; export class Claims extends ClaimDefinition { - userId?: string; + userId?: SecureId; claims: string[] = []; + constructor(raw?:{userId?:string, claims?: string[]}) { + super(); + this.userId = raw?.userId ? SecureId.fromHash(raw.userId) : undefined; + this.claims = raw?.claims ?? []; + } + public static test(guardClaim: string, userClaims?: Claims): Boolean { return userClaims === undefined || userClaims.claims.some((x) => x === guardClaim); } @@ -17,7 +24,7 @@ export class ClaimsOrm { JOIN claims as c on uc.claim_id = c.id where uc.user_id = ${userId};`; const claims = new Claims(); - claims.userId = userId; + claims.userId = SecureId.fromID(userId); claims.claims = dbResults.map((x) => x.name); return claims; } diff --git a/src/orm/games.ts b/src/orm/games.ts index c461f83..930d50f 100644 --- a/src/orm/games.ts +++ b/src/orm/games.ts @@ -20,19 +20,12 @@ export class Game { } export class GamesOrm { - async create(model: CreateGameRequest, claims?: Claims): Promise { + async create(model: CreateGameRequest, claims?: Claims): Promise { await sql`INSERT INTO games (name, image_path, bgg_id) VALUES (${model.name}, ${Claims.test(Claims.GAMES.MANAGE_IMAGES, claims) ? model.imagePath : null}, ${model.bggId})`; const newGameId: string = (first(await sql`SELECT lastval();`) as any)?.lastval as string; - try { - return await this.get(SecureId.fromID(newGameId)); - } catch (error) { - if (error instanceof UnauthorizedError) { - return null; - } - throw error; - } + return await this.get(SecureId.fromID(newGameId)); } async get(id: SecureId): Promise { @@ -55,7 +48,7 @@ export class GamesOrm { }); } - async update(id: SecureId, patch: UpdateGameRequest, claims?: Claims): Promise { + async update(id: SecureId, patch: UpdateGameRequest, claims?: Claims): Promise { const gameToUpdate = await this.get(id); gameToUpdate.name = patch.name ?? gameToUpdate.name; gameToUpdate.bggId = patch.bggId ?? gameToUpdate.bggId; @@ -70,17 +63,10 @@ export class GamesOrm { image_path=${gameToUpdate.imagePath} WHERE id = ${id.raw}`; - try { - return await this.get(id); - } catch (error) { - if (error instanceof UnauthorizedError) { - return null; - } - throw error; - } + return await this.get(id); } - async drop(id: SecureId): Promise { + async drop(id: SecureId): Promise { // Ensure player exists before attempting to delete await this.get(id); await sql.transaction(async (tx) => { diff --git a/src/orm/invites.ts b/src/orm/invites.ts new file mode 100644 index 0000000..eb5debd --- /dev/null +++ b/src/orm/invites.ts @@ -0,0 +1,133 @@ +import { sql } from 'bun'; +import { first } from 'lodash'; +import { BadRequestError, InternalServerError, NotFoundError, UnauthorizedError } from '../utilities/errors'; +import { SecureId } from '../utilities/requestModels'; +import { createRandomString } from '../utilities/helpers'; +import { Resend } from 'resend'; +import { orm } from './orm'; +import { InviteEmail } from '../emails/invite'; +import { User } from './user'; +import { Claims } from './claims'; + +export class InvitesOrm { + async create( + { + email, + playerId, + invitedByUserId, + }: { + email: string; + playerId: SecureId; + invitedByUserId: SecureId; + }, + claims?: Claims, + ): Promise { + if (!Claims.test(Claims.ADMIN, claims)) { + const userInviteCount = ( + first( + await sql`SELECT COUNT(*) AS count + FROM user_invites + WHERE invited_by_user_id = ${invitedByUserId.raw}`, + ) as { count: number } + )?.count; + + if (process.env.MAX_INVITE_ALLOWANCE && userInviteCount >= parseInt(process.env.MAX_INVITE_ALLOWANCE)) { + throw new UnauthorizedError('Invite allowance reached.'); + } + + const inviteExists = ( + first( + await sql`SELECT COUNT(*) > 0 AS exists + FROM user_invites + WHERE player_id = ${playerId.raw} + OR email = ${email}`, + ) as { + exists: boolean; + } + )?.exists; + if (inviteExists) { + throw new BadRequestError('Player has already been invited.'); + } + } + + const playerHasUser = ( + first( + await sql`SELECT COUNT(*) > 0 AS exists + FROM users + WHERE player_id = ${playerId.raw} + OR email = ${email}`, + ) as { + exists: boolean; + } + )?.exists; + if (playerHasUser) { + throw new BadRequestError('User has already been invited.'); + } + + const player = await orm.players.get(playerId); + + const invitationCode = createRandomString(6); + await sql`INSERT INTO user_invites (invite_code, email, player_id, invited_by_user_id) + VALUES (${invitationCode}, ${email}, ${playerId.raw}, ${invitedByUserId.raw})`; + const newInviteId: string = (first(await sql`SELECT lastval();`) as any)?.lastval as string; + + const resend = new Resend(process.env.RESEND_KEY); + const resendResponse = await resend.emails.send({ + from: `${process.env.PRODUCT_NAME} `, + to: [email], + subject: "You've been invited!", + react: InviteEmail({ playerName: player.name, inviteCode: invitationCode }), + }); + + if (resendResponse.error) { + throw new InternalServerError(); + } + + await sql`UPDATE user_invites SET was_email_sent = true WHERE id=${newInviteId}`; + + return; + } + + async accept({ inviteCode, password }: { inviteCode: string; password: string }): Promise { + const invite: { + id: string; + email: string; + player_id: string; + accepted: boolean; + } = first(await sql`SELECT * FROM user_invites WHERE invite_code=${inviteCode} LIMIT 1`); + + if (!invite) { + throw new NotFoundError('Invalid invite code'); + } + + if (invite.accepted) { + throw new UnauthorizedError('Invite already accepted'); + } + + const playerHasUser = ( + first( + await sql`SELECT COUNT(*) > 0 AS exists + FROM users + WHERE player_id = ${invite.player_id} + OR email = ${invite.email}`, + ) as { + exists: boolean; + } + )?.exists; + if (playerHasUser) { + throw new BadRequestError('User has already been invited.'); + } + + const createdUser = await orm.users.create({ + email: invite.email, + playerId: SecureId.fromID(invite.player_id), + password, + }); + + await sql`UPDATE user_invites + SET accepted = true + WHERE id = ${invite.id}`; + + return createdUser; + } +} diff --git a/src/orm/orm.ts b/src/orm/orm.ts index d6699e0..9bcd7ac 100644 --- a/src/orm/orm.ts +++ b/src/orm/orm.ts @@ -2,12 +2,14 @@ import { ClaimsOrm } from './claims'; import { UsersOrm } from './user'; import { PlayersOrm } from './players'; import { GamesOrm } from './games'; +import { InvitesOrm } from './invites'; class Orm { readonly claims: ClaimsOrm = new ClaimsOrm(); readonly users: UsersOrm = new UsersOrm(); readonly players: PlayersOrm = new PlayersOrm(); readonly games: GamesOrm = new GamesOrm(); + readonly invites: InvitesOrm = new InvitesOrm(); } export const orm = new Orm(); diff --git a/src/orm/players.ts b/src/orm/players.ts index 840a2b7..7e366a0 100644 --- a/src/orm/players.ts +++ b/src/orm/players.ts @@ -28,26 +28,19 @@ export class Player { } export class PlayersOrm { - async create(model: { name: string }, claims?: Claims): Promise { + async create(model: { name: string }, claims?: Claims): Promise { await sql`INSERT INTO players (name) VALUES (${model.name})`; const newPlayerId: string = (first(await sql`SELECT lastval();`) as any)?.lastval as string; - try { - return await this.get(SecureId.fromID(newPlayerId), claims); - } catch (error) { - if (error instanceof UnauthorizedError) { - return null; - } - throw error; - } + return await this.get(SecureId.fromID(newPlayerId)); } async get(id: SecureId, claims?: Claims): Promise { if (!(Claims.test(Claims.ADMIN, claims) || Claims.test(Claims.PLAYERS.OTHER.READ, claims))) { throw new UnauthorizedError(); } else if (Claims.test(Claims.PLAYERS.SELF.READ, claims) && claims?.userId) { - const user = await orm.users.get(SecureId.fromHash(claims.userId)); + const user = await orm.users.get(claims.userId); if (id.raw !== user.playerId.raw) { throw new UnauthorizedError(); } @@ -66,21 +59,59 @@ export class PlayersOrm { return new Player({ id: SecureId.fromID(dbResult.id), name: dbResult.name, - elo: dbResult.elo, + elo: parseInt(dbResult.elo), isRatingLocked: dbResult.is_rating_locked, canBeMultiple: dbResult.can_be_multiple, }); } - async update( - id: SecureId, - patch: UpdatePlayerRequest, - claims?: Claims, - ): Promise { + async list(claims?: Claims): Promise { + if (!claims || Claims.test(Claims.ADMIN, claims)) { + return (await sql`SELECT * FROM players`).map( + (x: { id: string; name: string; elo: string; is_rating_locked: boolean; can_be_multiple: boolean }) => + new Player({ + id: SecureId.fromID(x.id), + name: x.name, + elo: parseInt(x.elo), + isRatingLocked: x.is_rating_locked, + canBeMultiple: x.can_be_multiple, + }), + ); + } + + if (!Claims.test(Claims.PLAYERS.OTHER.READ, claims)) { + throw new UnauthorizedError(); + } + + return ( + await sql`SELECT p.* + FROM + users u + JOIN player_circles upc on upc.player_id = u.id + JOIN circles c ON c.id = upc.circle_id + JOIN player_circles pc ON pc.circle_id = c.id + JOIN players p ON p.id = pc.player_id + WHERE + u.player_id = ${claims.userId?.raw} + AND + pc.player_id <> u.player_id` + ).map( + (x: { id: string; name: string; elo: string; is_rating_locked: boolean; can_be_multiple: boolean }) => + new Player({ + id: SecureId.fromID(x.id), + name: x.name, + elo: parseInt(x.elo), + isRatingLocked: x.is_rating_locked, + canBeMultiple: x.can_be_multiple, + }), + ); + } + + async update(id: SecureId, patch: UpdatePlayerRequest, claims?: Claims): Promise { if (!(Claims.test(Claims.ADMIN, claims) || Claims.test(Claims.PLAYERS.OTHER.UPDATE, claims))) { throw new UnauthorizedError(); } else if (Claims.test(Claims.PLAYERS.SELF.UPDATE, claims) && claims?.userId) { - const user = await orm.users.get(SecureId.fromHash(claims.userId)); + const user = await orm.users.get(claims.userId); if (id.raw !== user.playerId.raw) { throw new UnauthorizedError(); } @@ -97,21 +128,14 @@ export class PlayersOrm { can_be_multiple=${playerToUpdate.canBeMultiple} WHERE id = ${id.raw}`; - try { - return await this.get(id, claims); - } catch (error) { - if (error instanceof UnauthorizedError) { - return null; - } - throw error; - } + return await this.get(id); } - async drop(id: SecureId, claims?: Claims): Promise { + async drop(id: SecureId, claims?: Claims): Promise { if (!(Claims.test(Claims.ADMIN, claims) || Claims.test(Claims.PLAYERS.OTHER.DELETE, claims))) { throw new UnauthorizedError(); } else if (Claims.test(Claims.PLAYERS.SELF.DELETE, claims) && claims?.userId) { - const user = await orm.users.get(SecureId.fromHash(claims.userId)); + const user = await orm.users.get(claims.userId); if (id.raw !== user.playerId.raw) { throw new UnauthorizedError(); } diff --git a/src/orm/user.ts b/src/orm/user.ts index 9848e17..28a70de 100644 --- a/src/orm/user.ts +++ b/src/orm/user.ts @@ -9,14 +9,14 @@ import { orm } from './orm'; export class User { id: SecureId; playerId: SecureId; - name: string; + email: string; isAdmin: boolean; isActive: boolean; - constructor(id: SecureId, playerId: SecureId, name: string, isAdmin: boolean = false, isActive: boolean = true) { + constructor(id: SecureId, playerId: SecureId, email: string, isAdmin: boolean = false, isActive: boolean = true) { this.id = id; this.playerId = playerId; - this.name = name; + this.email = email; this.isAdmin = isAdmin; this.isActive = isActive; } @@ -24,23 +24,23 @@ export class User { export class UsersOrm { async create( - model: { username: string; password: string; playerId: SecureId }, + { email, password, playerId }: { email: string; password: string; playerId: SecureId }, claims?: Claims, - ): Promise { + ): Promise { const existingUser: any = first( await sql`SELECT id FROM users - WHERE username = ${model.username} + WHERE email = ${email} OR player_id = ${playerId.raw} LIMIT 1`, ); if (existingUser) { - throw new BadRequestError(`User ${model.username} already exists`); + throw new BadRequestError(`User or player already exists`); } const defaultClaims: number[] = await orm.claims.getDefaultClaims(); - const passwordHash = await argon2.hash(model.password); - await sql`INSERT INTO users (username, pass_hash, player_id) - VALUES (${model.username}, ${passwordHash}, ${model.playerId.raw})`; + const passwordHash = await argon2.hash(password); + await sql`INSERT INTO users (email, pass_hash, player_id) + VALUES (${email}, ${passwordHash}, ${playerId.raw})`; const newUserId: SecureId = SecureId.fromID((first(await sql`SELECT lastval();`) as any)?.lastval as string); await sql.transaction(async (tx) => { for (let i in defaultClaims) { @@ -49,14 +49,7 @@ export class UsersOrm { } }); - try { - return await this.get(newUserId, claims); - } catch (error) { - if (error instanceof UnauthorizedError) { - return null; - } - throw error; - } + return await this.get(newUserId); } async get(id: SecureId, claims?: Claims): Promise { @@ -64,7 +57,7 @@ export class UsersOrm { !( Claims.test(Claims.ADMIN, claims) || Claims.test(Claims.USERS.OTHER.READ, claims) || - (Claims.test(Claims.USERS.SELF.READ, claims) && id.raw === claims?.userId) + (Claims.test(Claims.USERS.SELF.READ, claims) && id === claims?.userId) ) ) { throw new UnauthorizedError(); @@ -85,21 +78,17 @@ export class UsersOrm { return new User( SecureId.fromID(dbResult.id), SecureId.fromID(dbResult.player_id), - dbResult.username, + dbResult.email, dbResult.is_admin, ); } - async update( - id: SecureId, - patch: UpdateUserRequest, - claims?: Claims, - ): Promise { + async update(id: SecureId, patch: UpdateUserRequest, claims?: Claims): Promise { if ( !( Claims.test(Claims.ADMIN, claims) || Claims.test(Claims.USERS.OTHER.UPDATE, claims) || - (Claims.test(Claims.USERS.SELF.UPDATE, claims) && id.raw === claims?.userId) + (Claims.test(Claims.USERS.SELF.UPDATE, claims) && id === claims?.userId) ) ) { throw new UnauthorizedError(); @@ -116,22 +105,15 @@ export class UsersOrm { is_admin=${userToUpdate.isAdmin} WHERE id = ${id.raw}`; - try { - return await this.get(id, claims); - } catch (error) { - if (error instanceof UnauthorizedError) { - return null; - } - throw error; - } + return await this.get(id); } - async drop(id: SecureId, claims?: Claims): Promise { + async drop(id: SecureId, claims?: Claims): Promise { if ( !( Claims.test(Claims.ADMIN, claims) || Claims.test(Claims.USERS.OTHER.DELETE, claims) || - (Claims.test(Claims.USERS.SELF.DELETE, claims) && id.raw === claims?.userId) + (Claims.test(Claims.USERS.SELF.DELETE, claims) && id === claims?.userId) ) ) { throw new UnauthorizedError(); @@ -148,17 +130,17 @@ export class UsersOrm { WHERE id = ${id.raw}`; }); - return null; + return; } async verifyCredentials( - username: string, + email: string, password: string, ): Promise<{ userId: SecureId; refreshCount: string } | null> { const dbResult: any = first( await sql`SELECT * FROM users - WHERE username = ${username} + WHERE email = ${email} AND is_active = true limit 1`, ); @@ -183,7 +165,6 @@ export class UsersOrm { WHERE id = ${id.raw} LIMIT 1`, ); - console.log(dbResult.refresh_count, refreshCount); return dbResult.refresh_count === refreshCount; } @@ -192,9 +173,9 @@ export class UsersOrm { oldPassword: string | null, newPassword: string, claims?: Claims, - ): Promise { + ): Promise { const isAdmin = Claims.test(Claims.ADMIN, claims); - if (!(isAdmin || (Claims.test(Claims.USERS.SELF.UPDATE, claims) && id.raw === claims?.userId))) { + if (!(isAdmin || (Claims.test(Claims.USERS.SELF.UPDATE, claims) && id === claims?.userId))) { throw new UnauthorizedError(); } diff --git a/src/routes/invite.ts b/src/routes/invite.ts new file mode 100644 index 0000000..c6c3128 --- /dev/null +++ b/src/routes/invite.ts @@ -0,0 +1,12 @@ +import { guard, unwrapMethod } from '../utilities/guard'; +import { Claims } from '../orm/claims'; +import invite from '../endpoints/invite'; + +export default { + '/api/invite': { + POST: guard(invite.create, [Claims.ADMIN, Claims.USERS.INVITE]), + }, + '/api/invite/accept': { + POST: unwrapMethod(invite.accept), + }, +}; diff --git a/src/routes/player.ts b/src/routes/player.ts index f36942d..13a6c31 100644 --- a/src/routes/player.ts +++ b/src/routes/player.ts @@ -11,4 +11,7 @@ export default { PATCH: guard(player.update, [Claims.ADMIN, Claims.PLAYERS.OTHER.UPDATE, Claims.PLAYERS.SELF.UPDATE]), DELETE: guard(player.drop, [Claims.ADMIN, Claims.PLAYERS.OTHER.DELETE, Claims.PLAYERS.SELF.DELETE]), }, + '/api/player/list': { + GET: guard(player.list, [Claims.ADMIN, Claims.PLAYERS.OTHER.READ]), + }, }; diff --git a/src/routes/user.ts b/src/routes/user.ts index e1baff2..12ac068 100644 --- a/src/routes/user.ts +++ b/src/routes/user.ts @@ -1,4 +1,4 @@ -import { guard } from '../utilities/guard'; +import { guard, unwrap, unwrapMethod } from '../utilities/guard'; import user from '../endpoints/user'; import { Claims } from '../orm/claims'; @@ -6,6 +6,12 @@ export default { '/api/user': { POST: guard(user.create, [Claims.ADMIN, Claims.USERS.CREATE]), }, + '/api/user/invite': { + POST: guard(user.invite, [Claims.ADMIN, Claims.USERS.INVITE]), + }, + '/api/user/invite/accept': { + POST: unwrapMethod(user.acceptInvite), + }, '/api/user/:id': { GET: guard(user.get, [Claims.ADMIN, Claims.USERS.OTHER.READ, Claims.USERS.SELF.READ]), PATCH: guard(user.update, [Claims.ADMIN, Claims.USERS.OTHER.UPDATE, Claims.USERS.SELF.UPDATE]), diff --git a/src/utilities/claimDefinitions.ts b/src/utilities/claimDefinitions.ts index d8be20c..41512c2 100644 --- a/src/utilities/claimDefinitions.ts +++ b/src/utilities/claimDefinitions.ts @@ -2,6 +2,7 @@ export class ClaimDefinition { public static readonly ADMIN = 'ADMIN'; public static readonly USERS = { CREATE: 'USERS_CREATE', + INVITE: 'USERS_INVITE', SELF: { READ: 'USERS_SELF_READ', UPDATE: 'USERS_SELF_UPDATE', diff --git a/src/utilities/errors.ts b/src/utilities/errors.ts index 54b117b..6ec87fe 100644 --- a/src/utilities/errors.ts +++ b/src/utilities/errors.ts @@ -4,6 +4,12 @@ export class BadRequestError extends Error { } } +export class InternalServerError extends Error { + constructor(message?: string | undefined) { + super(message); + } +} + export class UnauthorizedError extends Error { constructor(message?: string | undefined) { super(message); diff --git a/src/utilities/guard.ts b/src/utilities/guard.ts index 5d5e586..eeac0f3 100644 --- a/src/utilities/guard.ts +++ b/src/utilities/guard.ts @@ -26,12 +26,13 @@ export function guard( const authHeader: string | null = (request.headers.get('Authorization')?.replace(/^Bearer /, '') as string) ?? null; try { - const userClaims: Claims = jwt.verify(authHeader as string, process.env.JWT_SECRET_KEY as string) as Claims; + const userClaims: Claims = new Claims(jwt.verify(authHeader as string, process.env.JWT_SECRET_KEY as string) as any); if (!userClaims.claims.some((x: string): boolean => guardedClaims.includes(x))) { return new UnauthorizedResponse('Unauthorized'); } return method(await unwrap(request, userClaims)); } catch (error: any) { + console.log(error) if (error instanceof TokenExpiredError) { return new UnauthorizedResponse(error.message); } diff --git a/src/utilities/helpers.ts b/src/utilities/helpers.ts index 2369bae..98cecca 100644 --- a/src/utilities/helpers.ts +++ b/src/utilities/helpers.ts @@ -18,3 +18,20 @@ export function memo {}, S>( return cache[key].value; }) as unknown as T; } + +export function createRandomString(length:number = 6):string { + const maxRandStringVal = parseInt(''.padEnd(length, 'z'), 36); + return Math.floor(Math.random() * maxRandStringVal).toString(36).toUpperCase(); +} + +export const brandColours = { + dark: '#14111C', + mid: '#CBCACB', + light: '#FBF8FC', + white: '#FFFFFF', + black: '#000000', + primary: '#CA00E7', + secondary: '#FFB527', + tertiary: '#6ED500', + danger: '#CA3211', +}; \ No newline at end of file diff --git a/src/utilities/requestModels.ts b/src/utilities/requestModels.ts index cf66089..4459fc4 100644 --- a/src/utilities/requestModels.ts +++ b/src/utilities/requestModels.ts @@ -1,7 +1,7 @@ import { hashIds } from './guard'; export interface LoginRequest { - username: string; + email: string; password: string; } export interface ChangePasswordRequest { @@ -9,7 +9,7 @@ export interface ChangePasswordRequest { newPassword: string; } export interface CreateUserRequest { - username: string; + email: string; password: string; playerId: string; } @@ -17,13 +17,21 @@ export interface UpdateUserRequest { isActive?: boolean; isAdmin?: boolean; } +export interface InviteUserRequest { + email: string; + playerId: string; +} +export interface AcceptInviteRequest { + inviteCode: string; + password: string; +} export interface CreatePlayerRequest { name: string; } export interface UpdatePlayerRequest { name?: string; - isRatingLocked?:boolean; - canBeMultiple?:boolean; + isRatingLocked?: boolean; + canBeMultiple?: boolean; } export interface CreateGameRequest { name: string; @@ -65,6 +73,10 @@ export class SecureId { return this.#hashedValue; } + valueOf(): string | undefined { + return this.#secureValue; + } + public static fromHash(hash: string) { return new SecureId({ public: hash }); } diff --git a/tsconfig.json b/tsconfig.json index 8d13adf..a460278 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,5 +6,6 @@ "forceConsistentCasingInFileNames": true, "strict": true, "skipLibCheck": true, + "jsx": "react" } }