Skip to content

Commit

Permalink
feat: Add Update List endpoint
Browse files Browse the repository at this point in the history
  • Loading branch information
Kevin Szuchet committed May 18, 2023
1 parent aa31602 commit 7bd61e6
Show file tree
Hide file tree
Showing 13 changed files with 820 additions and 29 deletions.
156 changes: 155 additions & 1 deletion src/controllers/handlers/lists-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ import {
import { TPick } from '../../adapters/picks'
import { isErrorWithMessage } from '../../logic/errors'
import { getPaginationParams } from '../../logic/http'
import { DEFAULT_LIST_ID } from '../../migrations/1678303321034_default-list'
import { Permission } from '../../ports/access'
import { AccessNotFoundError, DuplicatedAccessError } from '../../ports/access/errors'
import { AddListRequestBody, ListSortBy, ListSortDirection } from '../../ports/lists'
import { AddListRequestBody, ListSortBy, ListSortDirection, UpdateListRequestBody } from '../../ports/lists'
import {
DuplicatedListError,
ItemNotFoundError,
Expand Down Expand Up @@ -572,3 +573,156 @@ export async function deleteListHandler(
throw error
}
}

export async function updateListHandler(
context: Pick<HandlerContextWithPath<'lists', '/v1/lists/:id'>, 'components' | 'params' | 'request' | 'verification'>
): Promise<HTTPResponse<List>> {
const {
components: { lists },
verification,
params,
request
} = context
const userAddress: string | undefined = verification?.auth.toLowerCase()
let body: UpdateListRequestBody

if (!userAddress) {
return {
status: StatusCode.UNAUTHORIZED,
body: {
ok: false,
message: 'Unauthorized'
}
}
}

try {
body = await request.json()

if (!body.name && typeof body.private === 'undefined') {
return {
status: StatusCode.BAD_REQUEST,
body: {
ok: false,
message: 'The body must contain at least one of the following properties: name or private.'
}
}
}

if (body.name && typeof body.name !== 'string') {
return {
status: StatusCode.BAD_REQUEST,
body: {
ok: false,
message: 'The property name is not of string type.'
}
}
} else if (body.name && params.id === DEFAULT_LIST_ID) {
return {
status: StatusCode.BAD_REQUEST,
body: {
ok: false,
message: 'The name of the default list cannot be modified.'
}
}
}

if (body.private && typeof body.private !== 'boolean') {
return {
status: StatusCode.BAD_REQUEST,
body: {
ok: false,
message: 'The property private is not of boolean type.'
}
}
}

if (body.description && typeof body.description !== 'string') {
return {
status: StatusCode.BAD_REQUEST,
body: {
ok: false,
message: 'The property description is not of string type.'
}
}
}
} catch (error) {
return {
status: StatusCode.BAD_REQUEST,
body: {
ok: false,
message: 'The body must contain a parsable JSON.'
}
}
}

try {
const updateListResult = await lists.updateList(params.id, userAddress, body)

return {
status: StatusCode.UPDATED,
body: {
ok: true,
data: fromDBListToList(updateListResult)
}
}
} catch (error) {
if (error instanceof ListNotFoundError) {
return {
status: StatusCode.NOT_FOUND,
body: {
ok: false,
message: error.message,
data: {
listId: error.listId
}
}
}
}

if (error instanceof AccessNotFoundError) {
return {
status: StatusCode.NOT_FOUND,
body: {
ok: false,
message: error.message,
data: {
listId: error.listId,
permission: error.permission,
grantee: error.grantee
}
}
}
}

if (error instanceof DuplicatedListError) {
return {
status: StatusCode.UNPROCESSABLE_CONTENT,
body: {
ok: false,
message: error.message,
data: {
name: error.name
}
}
}
}

if (error instanceof DuplicatedAccessError) {
return {
status: StatusCode.CONFLICT,
body: {
ok: false,
message: error.message,
data: {
listId: error.listId,
permission: error.permission,
grantee: error.grantee
}
}
}
}

throw error
}
}
12 changes: 11 additions & 1 deletion src/controllers/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import {
deleteListHandler,
getListsHandler,
createAccessHandler,
getListHandler
getListHandler,
updateListHandler
} from './handlers/lists-handlers'
import { getPickStatsHandler, getPicksByItemIdHandler, getPickStatsOfItemHandler } from './handlers/picks-handlers'
import { pingHandler } from './handlers/ping-handler'
Expand Down Expand Up @@ -96,6 +97,15 @@ export function setupRouter(_globalContext: GlobalContext): Promise<Router<Globa
createListHandler
)

router.put(
'/v1/lists/:id',
authorizationMiddleware.wellKnownComponents({
optional: false,
expiration: FIVE_MINUTES
}),
updateListHandler
)

router.post(
'/v1/lists/:id/access',
authorizationMiddleware.wellKnownComponents({
Expand Down
23 changes: 6 additions & 17 deletions src/ports/access/component.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,26 @@
import SQL from 'sql-template-strings'
import { AppComponents } from '../../types'
import { AccessNotFoundError, DuplicatedAccessError } from './errors'
import { IAccessComponent, Permission } from './types'
import { deleteAccessQuery, insertAccessQuery, validateAccessExists, validateDuplicatedAccess } from './utils'

export function createAccessComponent(components: Pick<AppComponents, 'pg' | 'logs' | 'lists'>): IAccessComponent {
const { pg, logs, lists } = components

const logger = logs.getLogger('Access component')

async function deleteAccess(listId: string, permission: Permission, grantee: string, listOwner: string): Promise<void> {
const result = await pg.query<void>(SQL`
DELETE FROM favorites.acl USING favorites.lists
WHERE favorites.acl.list_id = favorites.lists.id
AND favorites.acl.list_id = ${listId}
AND favorites.lists.user_address = ${listOwner}
AND favorites.acl.permission = ${permission}
AND favorites.acl.grantee = ${grantee}`)

if (!result.rowCount) {
throw new AccessNotFoundError(listId, permission, grantee)
}
const result = await pg.query<void>(deleteAccessQuery(listId, permission, grantee, listOwner))

validateAccessExists(listId, permission, grantee, result)

logger.info(`Deleted access ${permission} for ${grantee} of the list ${listId}`)
}

async function createAccess(listId: string, permission: Permission, grantee: string, listOwner: string): Promise<void> {
try {
await lists.getList(listId, { userAddress: listOwner, considerDefaultList: false })
await pg.query<void>(SQL`INSERT INTO favorites.acl (list_id, permission, grantee) VALUES (${listId}, ${permission}, ${grantee})`)
await pg.query<void>(insertAccessQuery(listId, permission, grantee))
} catch (error) {
if (error && typeof error === 'object' && 'constraint' in error && error.constraint === 'list_id_permissions_grantee_primary_key') {
throw new DuplicatedAccessError(listId, permission, grantee)
}
validateDuplicatedAccess(listId, permission, grantee, error)

throw error
}
Expand Down
26 changes: 26 additions & 0 deletions src/ports/access/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import SQL from 'sql-template-strings'
import { AccessNotFoundError, DuplicatedAccessError } from './errors'
import { Permission } from './types'

export const deleteAccessQuery = (listId: string, permission: Permission, grantee: string, listOwner: string) => SQL`
DELETE FROM favorites.acl USING favorites.lists
WHERE favorites.acl.list_id = favorites.lists.id
AND favorites.acl.list_id = ${listId}
AND favorites.lists.user_address = ${listOwner}
AND favorites.acl.permission = ${permission}
AND favorites.acl.grantee = ${grantee}`

export function validateAccessExists(listId: string, permission: Permission, grantee: string, result: { rowCount: number }) {
if (!result.rowCount) {
throw new AccessNotFoundError(listId, permission, grantee)
}
}

export const insertAccessQuery = (listId: string, permission: Permission, grantee: string) =>
SQL`INSERT INTO favorites.acl (list_id, permission, grantee) VALUES (${listId}, ${permission}, ${grantee})`

export function validateDuplicatedAccess(listId: string, permission: Permission, grantee: string, error: unknown) {
if (error && typeof error === 'object' && 'constraint' in error && error.constraint === 'list_id_permissions_grantee_primary_key') {
throw new DuplicatedAccessError(listId, permission, grantee)
}
}
58 changes: 53 additions & 5 deletions src/ports/lists/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { isErrorWithMessage } from '../../logic/errors'
import { DEFAULT_LIST_USER_ADDRESS } from '../../migrations/1678303321034_default-list'
import { AppComponents } from '../../types'
import { Permission } from '../access'
import { AccessNotFoundError } from '../access/errors'
import { deleteAccessQuery, insertAccessQuery, validateAccessExists, validateDuplicatedAccess } from '../access/utils'
import { DBGetFilteredPicksWithCount, DBPick } from '../picks'
import {
DuplicatedListError,
Expand All @@ -21,8 +23,10 @@ import {
GetListsParameters,
ListSortBy,
ListSortDirection,
GetListOptions
GetListOptions,
UpdateListRequestBody
} from './types'
import { validateListExists } from './utils'

const GRANTED_TO_ALL = '*'

Expand Down Expand Up @@ -185,16 +189,60 @@ export function createListsComponent(
}
}

async function updateList(id: string, userAddress: string, updatedList: UpdateListRequestBody): Promise<DBList> {
const { name, description, private: isPrivate } = updatedList

const client = await pg.getPool().connect()
const accessQuery = isPrivate
? deleteAccessQuery(id, Permission.VIEW, GRANTED_TO_ALL, userAddress)
: insertAccessQuery(id, Permission.VIEW, GRANTED_TO_ALL)

try {
await client.query('BEGIN')

const [updatedListResult, accessResult] = await Promise.all([
client.query<DBList>(
SQL`UPDATE favorites.lists SET (name, description) VALUES (${name}, ${description})
WHERE id = ${id} AND user_address = ${userAddress}
RETURNING *`
),
client.query(accessQuery)
])
await client.query('COMMIT')

validateListExists(id, updatedListResult)

if (isPrivate) validateAccessExists(id, Permission.VIEW, GRANTED_TO_ALL, accessResult)

return updatedListResult.rows[0]
} catch (error) {
await client.query('ROLLBACK')

if (error instanceof ListNotFoundError || error instanceof AccessNotFoundError) throw error

if (error && typeof error === 'object' && 'constraint' in error && error.constraint === 'name_user_address_unique') {
throw new DuplicatedListError(name)
}

validateDuplicatedAccess(id, Permission.VIEW, GRANTED_TO_ALL, error)

throw new Error("The list couldn't be updated")
} finally {
// TODO: handle the following eslint-disable statement
// eslint-disable-next-line @typescript-eslint/await-thenable
await client.release()
}
}

async function deleteList(id: string, userAddress: string): Promise<void> {
const result = await pg.query(
SQL`DELETE FROM favorites.lists
WHERE favorites.lists.id = ${id}
AND favorites.lists.user_address = ${userAddress}`
)
if (result.rowCount === 0) {
throw new ListNotFoundError(id)
}

validateListExists(id, result)
}

return { getPicksByListId, addPickToList, deletePickInList, getLists, addList, deleteList, getList }
return { getPicksByListId, addPickToList, deletePickInList, getLists, addList, deleteList, getList, updateList }
}
5 changes: 5 additions & 0 deletions src/ports/lists/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface IListsComponents {
addList(newList: AddListRequestBody): Promise<DBList>
deleteList(id: string, userAddress: string): Promise<void>
getList(listId: string, options?: GetListOptions): Promise<DBList>
updateList(id: string, userAddress: string, updatedList: UpdateListRequestBody): Promise<DBList>
}

export type GetAuthenticatedAndPaginatedParameters = {
Expand Down Expand Up @@ -46,6 +47,10 @@ export type AddListRequestBody = {
userAddress: string
}

export type UpdateListRequestBody = Pick<AddListRequestBody, 'name' | 'description'> & {
private?: boolean
}

export enum ListSortBy {
CREATED_AT = 'createdAt',
NAME = 'name'
Expand Down
7 changes: 7 additions & 0 deletions src/ports/lists/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { ListNotFoundError } from './errors'

export function validateListExists(id: string, result: { rowCount: number }) {
if (result.rowCount === 0) {
throw new ListNotFoundError(id)
}
}
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export type Context<Path extends string = never> = IHttpServerComponent.PathAwar
export enum StatusCode {
OK = 200,
CREATED = 201,
UPDATED = 204,
BAD_REQUEST = 400,
UNAUTHORIZED = 401,
FORBIDDEN = 403,
Expand Down
Loading

0 comments on commit 7bd61e6

Please sign in to comment.