Finished initial implementation of circle endpoints

This commit is contained in:
jd
2026-03-02 23:52:12 +00:00
parent dee5b2429d
commit c23536b3ed
19 changed files with 549 additions and 30 deletions

View File

@@ -0,0 +1,30 @@
info:
name: Create
type: http
seq: 1
http:
method: POST
url: "{{BASE_URL}}/{{SECTOR}}"
body:
type: json
data: |-
{
"name": "{{Name}}",
"isPublic": {{IsPublic}},
"colour": "#FFFFFF"
}
auth: inherit
runtime:
variables:
- name: Name
value: Test Private Circle
- name: IsPublic
value: "false"
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

View File

@@ -0,0 +1,20 @@
info:
name: Delete
type: http
seq: 4
http:
method: DELETE
url: "{{BASE_URL}}/{{SECTOR}}/{{CircleID}}"
auth: inherit
runtime:
variables:
- name: CircleID
value: 6Z4214
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

View File

@@ -0,0 +1,20 @@
info:
name: Get
type: http
seq: 2
http:
method: GET
url: "{{BASE_URL}}/{{SECTOR}}/{{CircleID}}"
auth: inherit
runtime:
variables:
- name: CircleID
value: P17GOJ
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

View File

@@ -0,0 +1,24 @@
info:
name: Search
type: http
seq: 5
http:
method: GET
url: "{{BASE_URL}}/{{SECTOR}}/search/{{Query}}/{{PageSize}}/{{Page}}"
auth: inherit
runtime:
variables:
- name: Query
value: test
- name: PageSize
value: "5"
- name: Page
value: "1"
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

View File

@@ -0,0 +1,28 @@
info:
name: Update
type: http
seq: 3
http:
method: PATCH
url: "{{BASE_URL}}/{{SECTOR}}/{{CircleID}}"
body:
type: json
data: |-
{
"name":"{{Name}}"
}
auth: inherit
runtime:
variables:
- name: CircleID
value: 6Z4214
- name: Name
value: Test update
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

View File

@@ -0,0 +1,10 @@
info:
name: Circle
type: folder
seq: 1
request:
auth: inherit
variables:
- name: SECTOR
value: circles

View File

@@ -17,9 +17,9 @@ http:
runtime:
variables:
- name: GameID
value: ""
value: DM5GMY
- name: Name
value: ""
value: Update test
settings:
encodeUrl: true

View File

@@ -1,4 +1,3 @@
#file: noinspection SpellCheckingInspection,SpellCheckingInspection,SpellCheckingInspection,SpellCheckingInspection
opencollection: 1.0.0
info:
@@ -19,16 +18,6 @@ request:
auth:
type: bearer
token: "{{BEARER_TOKEN}}"
actions:
- type: set-variable
phase: after-response
selector:
expression: ${token}
method: jsonq
variable:
name: Token
scope: runtime
disabled: true
bundled: false
extensions:
bruno:

74
src/endpoints/circles.ts Normal file
View File

@@ -0,0 +1,74 @@
import { orm } from '../orm/orm';
import { UnwrappedRequest } from '../utilities/guard';
import { CreatedResponse, ErrorResponse, OkResponse, PagedResponse } from '../utilities/responseHelper';
import { CreateCircleRequest, InviteToCircleRequest, UpdateCircleRequest } from '../utilities/requestModels';
import { CircleId, PlayerId, UserId } from '../utilities/secureIds';
async function create(request: UnwrappedRequest<CreateCircleRequest>): Promise<Response> {
try {
return new CreatedResponse(await orm.circles.create(request.body, request.claims));
} catch (error: any) {
return new ErrorResponse(error as Error);
}
}
async function invite(request: UnwrappedRequest<InviteToCircleRequest>): Promise<Response> {
try {
let relatedEntityId: UserId | PlayerId | string | undefined;
if (request.body.userId) {
relatedEntityId = UserId.fromHash(request.body.userId);
} else if (request.body.playerId) {
relatedEntityId = PlayerId.fromHash(request.body.playerId);
} else {
relatedEntityId = request.body.email;
}
return new CreatedResponse(
await orm.circles.invite(CircleId.fromHash(request.params.id), relatedEntityId, request.claims),
);
} catch (error: any) {
return new ErrorResponse(error as Error);
}
}
async function get(request: UnwrappedRequest): Promise<Response> {
try {
return new OkResponse(await orm.circles.get(CircleId.fromHash(request.params.id)));
} catch (error: any) {
return new ErrorResponse(error as Error);
}
}
async function update(request: UnwrappedRequest<UpdateCircleRequest>): Promise<Response> {
try {
return new OkResponse(
await orm.circles.update(CircleId.fromHash(request.params.id), request.body),
);
} catch (error: any) {
return new ErrorResponse(error as Error);
}
}
async function drop(request: UnwrappedRequest): Promise<Response> {
try {
return new OkResponse(await orm.circles.drop(CircleId.fromHash(request.params.id)));
} catch (error: any) {
return new ErrorResponse(error as Error);
}
}
async function query(request: UnwrappedRequest): Promise<Response> {
try {
return new PagedResponse(request, await orm.circles.query(request.params.query));
} catch (error: any) {
return new ErrorResponse(error as Error);
}
}
export default {
create,
get,
update,
drop,
query,
invite,
};

View File

@@ -8,6 +8,7 @@ import collections from './routes/collections';
import { buildRoute } from './utilities/routeBuilder';
import { MatchId } from './utilities/secureIds';
import matches from './routes/matches';
import circles from './routes/circles';
const server = Bun.serve({
routes: buildRoute({
@@ -19,6 +20,7 @@ const server = Bun.serve({
invites,
collections,
matches,
circles,
},
test: {
GET: () => {

188
src/orm/circles.ts Normal file
View File

@@ -0,0 +1,188 @@
import { Claims } from './claims';
import { sql } from 'bun';
import { first } from 'lodash';
import { BadRequestError, NotFoundError, UnauthorizedError } from '../utilities/errors';
import { CreateCircleRequest, UpdateCircleRequest } from '../utilities/requestModels';
import { memo } from '../utilities/helpers';
import { CircleId, PlayerId, UserId } from '../utilities/secureIds';
import { orm } from './orm';
import { User } from './user';
export class Circle {
id: CircleId;
owningUserId: UserId;
name: string;
isPublic: boolean;
imagePath?: string;
colour?: string;
constructor({
id,
owningUserId,
name,
isPublic,
imagePath,
colour,
}: {
id: CircleId;
owningUserId: UserId;
name: string;
isPublic: boolean;
imagePath?: string;
colour?: string;
}) {
this.id = id;
this.owningUserId = owningUserId;
this.name = name;
this.isPublic = isPublic;
this.imagePath = imagePath;
this.colour = colour;
}
}
export class CircleOrm {
async create(model: CreateCircleRequest, claims?: Claims): Promise<Circle> {
if (model.isPublic && claims && !claims.test(Claims.ADMIN, Claims.CIRCLES.PUBLIC.CREATE)) {
throw new UnauthorizedError();
}
await sql`INSERT INTO circles (owning_user_id, name, is_public, colour)
VALUES (${claims?.userId.raw},
${model.name},
${model.isPublic},
${model.colour})`;
const newCircleId: string = (first(await sql`SELECT lastval();`) as any)?.lastval as string;
return await this.get(CircleId.fromID(newCircleId));
}
async get(id: CircleId, claims?: Claims): Promise<Circle> {
const circleResult: any = first(
await sql`SELECT *
FROM circles
WHERE id = ${id.raw}
LIMIT 1`,
);
if (!circleResult) {
throw new NotFoundError('No matching game exists');
}
let user: User;
if (
claims &&
!claims.test(Claims.ADMIN) &&
!(claims.test(Claims.CIRCLES.PUBLIC.READ) && circleResult.is_public) &&
!(claims.test(Claims.CIRCLES.OWNED.READ) && circleResult.owning_user_id === claims.userId.raw) &&
!(
claims.test(Claims.CIRCLES.PRIVATE.READ_IF_MEMBER) &&
(user = await orm.users.get(claims.userId)) &&
(await sql`SELECT * FROM player_circles WHERE circle_id = ${id.raw}`).some(
(x: { player_id: string }) => x.player_id === user.playerId.raw,
)
)
) {
throw new UnauthorizedError();
}
return new Circle({
id: CircleId.fromID(circleResult.id),
owningUserId: UserId.fromID(circleResult.owning_user_id),
name: circleResult.name,
isPublic: circleResult.is_public,
imagePath: circleResult.image_path,
colour: circleResult.colour,
});
}
async update(id: CircleId, patch: UpdateCircleRequest): Promise<Circle> {
const recordToUpdate = await this.get(id);
recordToUpdate.name = patch.name ?? recordToUpdate.name;
recordToUpdate.colour = patch.colour ?? recordToUpdate.colour;
recordToUpdate.imagePath = patch.imagePath ?? recordToUpdate.imagePath;
await sql`UPDATE circles
SET name=${recordToUpdate.name},
colour=${recordToUpdate.colour},
image_path=${recordToUpdate.imagePath}
WHERE id = ${id.raw}`;
return await this.get(id);
}
async drop(id: CircleId): Promise<void> {
// Ensure record exists before attempting to delete
await this.get(id);
await sql.transaction(async (tx) => {
await tx`DELETE
FROM player_circles
WHERE circle_id = ${id.raw}`;
await tx`DELETE
FROM circle_invites
WHERE circle_id = ${id.raw}`;
await tx`DELETE
FROM circle_comments
WHERE circle_id = ${id.raw}`;
await tx`DELETE
FROM circles
WHERE id = ${id.raw}`;
});
return;
}
async invite(circleId: CircleId, relatedRecord: PlayerId | UserId | string | undefined, claims: Claims): Promise<void> {
if(relatedRecord === undefined) {
throw new BadRequestError();
}
const circle = await this.get(circleId, claims);
if (
claims &&
((circle.isPublic && !claims.test(Claims.CIRCLES.PUBLIC.USERS.INVITE)) ||
(!circle.isPublic &&
!(claims.test(Claims.CIRCLES.OWNED.USERS.INVITE) && circle.owningUserId === claims.userId)))
) {
throw new UnauthorizedError();
}
let invitedUserId: UserId | undefined;
if (relatedRecord instanceof UserId) {
invitedUserId = relatedRecord;
} else if (relatedRecord instanceof PlayerId) {
invitedUserId = (await orm.users.getByPlayer(relatedRecord))?.id;
} else {
invitedUserId = (await orm.users.getByEmail(relatedRecord))?.id;
}
if (!invitedUserId) {
throw new BadRequestError();
}
await sql`INSERT INTO circle_invites(invited_user_id, invited_by_user_id, circle_id)
VALUES (${invitedUserId.raw}, ${claims.userId.raw}, ${circleId.raw})`;
}
query: (query: string) => Promise<Circle[]> = memo<(query: string) => Promise<Circle[]>, Circle[]>(this.#query);
async #query(query: string): Promise<Circle[]> {
const dbResult: any = await sql` SELECT
id, name, owning_user_id, is_public
FROM (SELECT *, SIMILARITY(${query}, name) as similarity FROM circles WHERE is_public=true)
WHERE similarity > 0
ORDER BY similarity
LIMIT 5;`;
if (!dbResult) {
throw new NotFoundError('No matching circles exists');
}
return dbResult.map(
(x: { id: string; name: string; owning_user_id: string; is_public: boolean }) =>
new Circle({
id: CircleId.fromID(x.id),
name: x.name,
isPublic: x.is_public,
owningUserId: UserId.fromID(x.owning_user_id),
}),
);
}
}

View File

@@ -5,6 +5,7 @@ import { GamesOrm } from './games';
import { InvitesOrm } from './invites';
import { CollectionsOrm } from './collections';
import { MatchOrm } from './matches';
import { CircleOrm } from './circles';
class Orm {
readonly claims: ClaimsOrm = new ClaimsOrm();
@@ -14,6 +15,7 @@ class Orm {
readonly invites: InvitesOrm = new InvitesOrm();
readonly collections: CollectionsOrm = new CollectionsOrm();
readonly matches: MatchOrm = new MatchOrm();
readonly circles: CircleOrm = new CircleOrm();
}
export const orm = new Orm();

View File

@@ -89,6 +89,69 @@ export class UsersOrm {
);
}
async getByPlayer(id: PlayerId, claims?: Claims): Promise<User> {
const dbResult: any = first(
await sql`SELECT *
FROM users
WHERE player_id = ${id.raw}
AND is_active = true
LIMIT 1`,
);
if (!dbResult) {
throw new NotFoundError('No matching user exists');
}
if (
claims &&
!(
claims.test(Claims.ADMIN, Claims.USERS.OTHER.READ) ||
(claims.test(Claims.USERS.SELF.READ) && dbResult.id === claims?.userId.raw)
)
) {
throw new UnauthorizedError();
}
return new User(
UserId.fromID(dbResult.id),
PlayerId.fromID(dbResult.player_id),
dbResult.email,
dbResult.is_admin,
);
}
async getByEmail(email:string, claims?: Claims): Promise<User> {
const dbResult: any = first(
await sql`SELECT *
FROM users
WHERE email = ${email}
AND is_active = true
LIMIT 1`,
);
if (!dbResult) {
throw new NotFoundError('No matching user exists');
}
if (
claims &&
!(
claims.test(Claims.ADMIN, Claims.USERS.OTHER.READ) ||
(claims.test(Claims.USERS.SELF.READ) && dbResult.id === claims?.userId.raw)
)
) {
throw new UnauthorizedError();
}
return new User(
UserId.fromID(dbResult.id),
PlayerId.fromID(dbResult.player_id),
dbResult.email,
dbResult.is_admin,
);
}
async update(id: UserId, patch: UpdateUserRequest, claims?: Claims): Promise<User> {
if (
claims &&

30
src/routes/circles.ts Normal file
View File

@@ -0,0 +1,30 @@
import { guard } from '../utilities/guard';
import { Claims } from '../orm/claims';
import circles from '../endpoints/circles';
export default {
'POST': guard(circles.create, [Claims.ADMIN, Claims.CIRCLES.PUBLIC.CREATE, Claims.CIRCLES.PRIVATE.CREATE]),
':id': {
GET: guard(circles.get, [
Claims.ADMIN,
Claims.CIRCLES.PUBLIC.READ,
Claims.CIRCLES.PRIVATE.READ,
Claims.CIRCLES.PRIVATE.READ_IF_MEMBER,
]),
PATCH: guard(circles.update, [Claims.ADMIN, Claims.CIRCLES.OWNED.UPDATE]),
DELETE: guard(circles.drop, [Claims.ADMIN, Claims.CIRCLES.OWNED.DELETE]),
invite: {
POST: guard(circles.invite, [
Claims.ADMIN,
Claims.CIRCLES.PUBLIC.USERS.INVITE,
Claims.CIRCLES.OWNED.USERS.INVITE,
]),
},
},
'search': {
':query': {
variants: [':pageSize/:page', ':page'],
GET: guard(circles.query, [Claims.ADMIN, Claims.CIRCLES.PUBLIC.READ]),
},
},
};

View File

@@ -36,7 +36,8 @@ export class ClaimDefinition {
ADD: 'CIRCLES_PUBLIC_COMMENTS_ADD',
},
USERS: {
INVITE: 'CIRCLES_OWNED_USER_INVITE',
INVITE: 'CIRCLES_PUBLIC_USER_INVITE',
LIST: 'CIRCLES_PUBLIC_USER_LIST',
},
},
PRIVATE: {

View File

@@ -54,3 +54,19 @@ export interface CreateMatchRequest {
gameId: string;
participants: { playerId: string; standing: number }[];
}
export interface CreateCircleRequest {
name: string;
isPublic: boolean;
imagePath?: string;
colour: string;
}
export interface UpdateCircleRequest {
name: string;
imagePath?: string;
colour: string;
}
export interface InviteToCircleRequest {
email?:string;
userId?:string;
playerId?:string;
}

View File

@@ -1,5 +1,5 @@
import { BadRequestError, NotFoundError, UnauthorizedError } from './errors';
import { clamp, isObject } from 'lodash';
import { clamp, isArray, isObject } from 'lodash';
import { UnwrappedRequest } from './guard';
export class ErrorResponse extends Response {
@@ -52,7 +52,7 @@ export class OkResponse extends Response {
constructor(body?: any) {
if (body) {
return Response.json(
isObject(body)
isObject(body) && !isArray(body)
? {
...body,
}

View File

@@ -28,6 +28,7 @@ export function buildRoute(
const endpointDefinition = {
POST: route.POST,
GET: route.GET,
PATCH: route.PATCH,
PUT: route.PUT,
DELETE: route.DELETE,
};

View File

@@ -66,83 +66,104 @@ class SecureId {
export class UserId extends SecureId {
protected static override hashPrefix: string = 'UserId';
// This method exists to force type errors when using an incorrect ID class.
#uniqueMethodUser(){}
public static fromHash(hash: string): UserId {
return super.fromHash(hash, UserId);
return super.fromHash(hash, UserId) as UserId;
}
public static fromID(id: string): UserId {
return super.fromID(id, UserId);
return super.fromID(id, UserId) as UserId;
}
}
export class PlayerId extends SecureId {
protected static override hashPrefix: string = 'PlayerId';
// This method exists to force type errors when using an incorrect ID class.
#uniqueMethodPlayer(){}
public static fromHash(hash: string): PlayerId {
return super.fromHash(hash, PlayerId);
return super.fromHash(hash, PlayerId) as PlayerId;
}
public static fromID(id: string): PlayerId {
return super.fromID(id, PlayerId);
return super.fromID(id, PlayerId) as PlayerId;
}
}
export class InviteId extends SecureId {
protected static override hashPrefix: string = 'InviteId';
// This method exists to force type errors when using an incorrect ID class.
#uniqueMethodInvite(){}
public static fromHash(hash: string): InviteId {
return super.fromHash(hash, InviteId);
return super.fromHash(hash, InviteId) as InviteId;
}
public static fromID(id: string): InviteId {
return super.fromID(id, InviteId);
return super.fromID(id, InviteId) as InviteId;
}
}
export class GameId extends SecureId {
protected static override hashPrefix: string = 'GameId';
// This method exists to force type errors when using an incorrect ID class.
#uniqueMethodGame(){}
public static fromHash(hash: string): GameId {
return super.fromHash(hash, GameId);
return super.fromHash(hash, GameId) as GameId;
}
public static fromID(id: string): GameId {
return super.fromID(id, GameId);
return super.fromID(id, GameId) as GameId;
}
}
export class CollectionId extends SecureId {
protected static override hashPrefix: string = 'CollectionId';
// This method exists to force type errors when using an incorrect ID class.
#uniqueMethodCollection(){}
public static fromHash(hash: string): CollectionId {
return super.fromHash(hash, CollectionId);
return super.fromHash(hash, CollectionId) as CollectionId;
}
public static fromID(id: string): CollectionId {
return super.fromID(id, CollectionId);
return super.fromID(id, CollectionId) as CollectionId;
}
}
export class MatchId extends SecureId {
protected static override hashPrefix: string = 'MatchId';
// This method exists to force type errors when using an incorrect ID class.
#uniqueMethodMatch(){}
public static fromHash(hash: string): MatchId {
return super.fromHash(hash, MatchId);
return super.fromHash(hash, MatchId) as MatchId;
}
public static fromID(id: string): MatchId {
return super.fromID(id, MatchId);
return super.fromID(id, MatchId) as MatchId;
}
}
export class CircleId extends SecureId {
protected static override hashPrefix: string = 'CircleId';
// This method exists to force type errors when using an incorrect ID class.
#uniqueMethodCircle(){}
public static fromHash(hash: string): CircleId {
return super.fromHash(hash, CircleId);
return super.fromHash(hash, CircleId) as CircleId;
}
public static fromID(id: string): CircleId {
return super.fromID(id, CircleId);
return super.fromID(id, CircleId) as CircleId;
}
}