Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 143 additions & 21 deletions apps/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -664,6 +664,115 @@
"tags": [
"items"
]
},
"patch": {
"operationId": "ItemsController_update",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateItemDto"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ItemDto"
}
}
}
},
"400": {
"description": "validation_error",
"content": {
"application/problem+json": {
"schema": {
"allOf": [
{
"$ref": "#/components/schemas/ProblemDetailsDto"
}
],
"properties": {
"code": {
"type": "string",
"enum": [
"validation_error"
]
}
}
}
}
}
},
"401": {
"description": "unauthenticated",
"content": {
"application/problem+json": {
"schema": {
"allOf": [
{
"$ref": "#/components/schemas/ProblemDetailsDto"
}
],
"properties": {
"code": {
"type": "string",
"enum": [
"unauthenticated"
]
}
}
}
}
}
},
"404": {
"description": "not_found",
"content": {
"application/problem+json": {
"schema": {
"allOf": [
{
"$ref": "#/components/schemas/ProblemDetailsDto"
}
],
"properties": {
"code": {
"type": "string",
"enum": [
"not_found"
]
}
}
}
}
}
}
},
"security": [
{
"bearer": []
}
],
"tags": [
"items"
]
}
},
"/v1/items/{id}/children": {
Expand Down Expand Up @@ -702,10 +811,10 @@
"required": false,
"in": "query",
"schema": {
"type": "string",
"enum": [
"folder"
],
"type": "string"
]
}
}
],
Expand Down Expand Up @@ -1598,25 +1707,6 @@
"updatedAt"
]
},
"ItemListDto": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ItemDto"
}
},
"nextCursor": {
"type": "string",
"nullable": true
}
},
"required": [
"items",
"nextCursor"
]
},
"ItemTrailEntryDto": {
"type": "object",
"properties": {
Expand Down Expand Up @@ -1650,6 +1740,38 @@
"trail"
]
},
"UpdateItemDto": {
"type": "object",
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 255
}
},
"required": [
"name"
]
},
"ItemListDto": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ItemDto"
}
},
"nextCursor": {
"type": "string",
"nullable": true
}
},
"required": [
"items",
"nextCursor"
]
},
"DownloadUrlDto": {
"type": "object",
"properties": {
Expand Down
8 changes: 8 additions & 0 deletions apps/api/src/modules/items/dto/update-item.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { PickType } from '@nestjs/swagger';
import { CreateFolderDto } from './create-folder.dto.js';

/**
* A atualização de um Item (seção 3.3 do docs/lld.md). Só o nome por ora: as
* regras dele são as do nome de uma pasta nova.
*/
export class UpdateItemDto extends PickType(CreateFolderDto, ['name']) {}
6 changes: 6 additions & 0 deletions apps/api/src/modules/items/entities/item.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ export type CreateFolderResult =
| { status: 'parent_not_found' }
| { status: 'too_deep' };

/** O que renomear um Item resolve: renomeado, a Pasta raiz (que não se renomeia) ou sem Item ativo do Usuário. */
export type RenameResult =
| { status: 'renamed'; item: Item }
| { status: 'root' }
| { status: 'not_found' };

/** Quantas pastas o caminho de um Item pode ter, sem contar a Pasta raiz. */
export const MAX_FOLDER_DEPTH = 50;

Expand Down
25 changes: 24 additions & 1 deletion apps/api/src/modules/items/items.controller.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,25 @@
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
import {
Body,
Controller,
Get,
Param,
Patch,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { AuthGuard } from '../../common/auth/auth.guard.js';
import { CurrentUserId } from '../../common/auth/current-user-id.decorator.js';
import { ApiProblem } from '../../common/openapi/api-problem.decorator.js';
import { DownloadUrlDto } from './dto/download-url.dto.js';
import { ItemDetailDto } from './dto/item-detail.dto.js';
import { ItemDto } from './dto/item.dto.js';
import { ItemListDto } from './dto/item-list.dto.js';
import {
DEFAULT_PAGE_SIZE,
ListChildrenQueryDto,
} from './dto/list-children-query.dto.js';
import { UpdateItemDto } from './dto/update-item.dto.js';
import { ItemsService } from './items.service.js';

@ApiTags('items')
Expand All @@ -30,6 +40,19 @@ export class ItemsController {
return this.items.getFolder(userId, id);
}

@Patch(':id')
@ApiOkResponse({ type: ItemDto })
@ApiProblem(400, 'validation_error')
@ApiProblem(401, 'unauthenticated')
@ApiProblem(404, 'not_found')
update(
@Param('id') id: string,
@Body() body: UpdateItemDto,
@CurrentUserId() userId: string,
): Promise<ItemDto> {
return this.items.rename(userId, id, body.name);
}

@Get(':id/children')
@ApiOkResponse({ type: ItemListDto })
@ApiProblem(400, 'validation_error')
Expand Down
31 changes: 31 additions & 0 deletions apps/api/src/modules/items/items.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
ItemPosition,
ItemType,
NewPendingFile,
RenameResult,
ROOT_FOLDER_NAME,
} from './entities/item.entity.js';

Expand Down Expand Up @@ -183,6 +184,36 @@ export class ItemsRepository {
});
}

/**
* Troca o nome do Item ativo do Usuário e renova o `updatedAt`, num único
* `UPDATE` condicionado a ele ainda estar ativo e não ser a Pasta raiz, a
* única sem pai: uma mudança concorrente de estado não escapa da condição.
* Sem linha atualizada, a leitura só diz por quê: a Pasta raiz, dita à
* parte, ou nenhum Item ativo do Usuário.
*/
async renameActive(
ownerId: string,
id: string,
name: string,
): Promise<RenameResult> {
const { count } = await this.prisma.item.updateMany({
where: { id, ownerId, status: 'active', parentId: { not: null } },
data: { name },
});
if (count === 0) {
const root = await this.prisma.item.findFirst({
where: { id, ownerId, status: 'active', parentId: null },
select: { id: true },
});
return { status: root ? 'root' : 'not_found' };
}
const row = await this.prisma.item.findUniqueOrThrow({
where: { id },
select: ITEM_FIELDS,
});
return { status: 'renamed', item: toItem(row) };
}

/** Cria o Item `pending` de um arquivo, invisível nas listagens. */
async createPendingFile(
file: NewPendingFile,
Expand Down
25 changes: 25 additions & 0 deletions apps/api/src/modules/items/items.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
ItemPage,
NewPendingFile,
} from './entities/item.entity.js';
import { InputValidationError } from '../../common/errors/input-validation.error.js';
import { ItemNotFoundError } from './errors/item-not-found.error.js';
import { MaxDepthExceededError } from './errors/max-depth-exceeded.error.js';
import { decodeItemCursor, encodeItemCursor } from './item-cursor.js';
Expand Down Expand Up @@ -118,6 +119,30 @@ export class ItemsService {
}
}

/**
* Troca o nome de um Item ativo do Usuário, que passa a ter a data de
* modificação de agora. A Pasta raiz, pelo id ou pelo atalho `root`, não se
* renomeia: é entrada inválida. Qualquer outro Item que o Usuário não vê
* (inexistente, de outro dono, `pending`, id que não é UUID) é `not_found`.
*/
async rename(ownerId: string, id: string, name: string): Promise<Item> {
const result = await this.items.renameActive(
ownerId,
await this.folderIdOf(ownerId, id),
name,
);
switch (result.status) {
case 'renamed':
return result.item;
case 'root':
throw new InputValidationError([
{ field: 'id', messages: ['A Pasta raiz não pode ser renomeada.'] },
]);
case 'not_found':
throw new ItemNotFoundError();
}
}

/**
* O id da pasta pedida: o da Pasta raiz, pelo atalho `root`, ou o próprio id.
* Um texto que não é UUID é `not_found`. Se a pasta existe, quem lê confere.
Expand Down
Loading
Loading