diff --git a/openapi.json b/openapi.json index e4df72d..e65cf8c 100644 --- a/openapi.json +++ b/openapi.json @@ -801,6 +801,1825 @@ "summary": "Update project" } }, + "/api/v1/projects/{id}/tags": { + "get": { + "operationId": "list_tags", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$defs": { + "TagResponse": { + "properties": { + "color": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "project_id": { + "type": "string" + } + }, + "required": [ + "id", + "project_id", + "name" + ], + "type": "object" + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "items": { + "$ref": "#/$defs/TagResponse" + }, + "title": "Array_of_TagResponse", + "type": "array" + } + } + }, + "description": "Success" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "User does not own this project" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Tag not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Tag name already exists in this project" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal server error" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Error response" + } + }, + "summary": "List tags" + }, + "post": { + "operationId": "create_tag", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true + } + ], + "responses": { + "200": { + "description": "Success" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "User does not own this project" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Tag not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Tag name already exists in this project" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal server error" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Error response" + } + }, + "summary": "Create tag" + } + }, + "/api/v1/projects/{id}/testimonials": { + "get": { + "operationId": "list_testimonials", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$defs": { + "TagResponse": { + "properties": { + "color": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "project_id": { + "type": "string" + } + }, + "required": [ + "id", + "project_id", + "name" + ], + "type": "object" + }, + "TestimonialResponse": { + "properties": { + "author_avatar_url": { + "type": [ + "string", + "null" + ] + }, + "author_company": { + "type": [ + "string", + "null" + ] + }, + "author_email": { + "type": [ + "string", + "null" + ] + }, + "author_name": { + "type": "string" + }, + "author_title": { + "type": [ + "string", + "null" + ] + }, + "author_url": { + "type": [ + "string", + "null" + ] + }, + "content": { + "type": [ + "string", + "null" + ] + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "is_approved": { + "type": "boolean" + }, + "is_featured": { + "type": "boolean" + }, + "language": { + "type": [ + "string", + "null" + ] + }, + "project_id": { + "type": "string" + }, + "rating": { + "format": "int16", + "maximum": 32767, + "minimum": -32768, + "type": [ + "integer", + "null" + ] + }, + "sentiment": { + "type": [ + "string", + "null" + ] + }, + "sentiment_score": { + "format": "float", + "type": [ + "number", + "null" + ] + }, + "source": { + "type": [ + "string", + "null" + ] + }, + "source_id": { + "type": [ + "string", + "null" + ] + }, + "source_platform": { + "type": [ + "string", + "null" + ] + }, + "source_url": { + "type": [ + "string", + "null" + ] + }, + "tags": { + "items": { + "$ref": "#/$defs/TagResponse" + }, + "type": "array" + }, + "transcription": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "video_duration_seconds": { + "format": "int32", + "type": [ + "integer", + "null" + ] + }, + "video_thumbnail_url": { + "type": [ + "string", + "null" + ] + }, + "video_url": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "project_id", + "type", + "author_name", + "is_approved", + "is_featured", + "tags", + "created_at", + "updated_at" + ], + "type": "object" + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "items": { + "$ref": "#/$defs/TestimonialResponse" + }, + "title": "Array_of_TestimonialResponse", + "type": "array" + } + } + }, + "description": "Success" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "User does not own this project" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Testimonial not found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal server error" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Error response" + } + }, + "summary": "List testimonials" + }, + "post": { + "operationId": "create_testimonial", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true + } + ], + "responses": { + "200": { + "description": "Success" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "User does not own this project" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Testimonial not found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal server error" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Error response" + } + }, + "summary": "Create testimonial" + } + }, + "/api/v1/tags/{id}": { + "delete": { + "operationId": "delete_tag", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true + } + ], + "responses": { + "200": { + "description": "Success" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "User does not own this project" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Tag not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Tag name already exists in this project" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal server error" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Error response" + } + }, + "summary": "Delete tag" + }, + "put": { + "operationId": "update_tag", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "color": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "project_id": { + "type": "string" + } + }, + "required": [ + "id", + "project_id", + "name" + ], + "title": "TagResponse", + "type": "object" + } + } + }, + "description": "Success" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "User does not own this project" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Tag not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Tag name already exists in this project" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal server error" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Error response" + } + }, + "summary": "Update tag" + } + }, + "/api/v1/testimonials/{id}": { + "delete": { + "operationId": "delete_testimonial", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true + } + ], + "responses": { + "200": { + "description": "Success" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "User does not own this project" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Testimonial not found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal server error" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Error response" + } + }, + "summary": "Delete testimonial" + }, + "get": { + "operationId": "get_testimonial", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$defs": { + "TagResponse": { + "properties": { + "color": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "project_id": { + "type": "string" + } + }, + "required": [ + "id", + "project_id", + "name" + ], + "type": "object" + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "author_avatar_url": { + "type": [ + "string", + "null" + ] + }, + "author_company": { + "type": [ + "string", + "null" + ] + }, + "author_email": { + "type": [ + "string", + "null" + ] + }, + "author_name": { + "type": "string" + }, + "author_title": { + "type": [ + "string", + "null" + ] + }, + "author_url": { + "type": [ + "string", + "null" + ] + }, + "content": { + "type": [ + "string", + "null" + ] + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "is_approved": { + "type": "boolean" + }, + "is_featured": { + "type": "boolean" + }, + "language": { + "type": [ + "string", + "null" + ] + }, + "project_id": { + "type": "string" + }, + "rating": { + "format": "int16", + "maximum": 32767, + "minimum": -32768, + "type": [ + "integer", + "null" + ] + }, + "sentiment": { + "type": [ + "string", + "null" + ] + }, + "sentiment_score": { + "format": "float", + "type": [ + "number", + "null" + ] + }, + "source": { + "type": [ + "string", + "null" + ] + }, + "source_id": { + "type": [ + "string", + "null" + ] + }, + "source_platform": { + "type": [ + "string", + "null" + ] + }, + "source_url": { + "type": [ + "string", + "null" + ] + }, + "tags": { + "items": { + "$ref": "#/$defs/TagResponse" + }, + "type": "array" + }, + "transcription": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "video_duration_seconds": { + "format": "int32", + "type": [ + "integer", + "null" + ] + }, + "video_thumbnail_url": { + "type": [ + "string", + "null" + ] + }, + "video_url": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "project_id", + "type", + "author_name", + "is_approved", + "is_featured", + "tags", + "created_at", + "updated_at" + ], + "title": "TestimonialResponse", + "type": "object" + } + } + }, + "description": "Success" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "User does not own this project" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Testimonial not found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal server error" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Error response" + } + }, + "summary": "Get testimonial" + }, + "put": { + "operationId": "update_testimonial", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$defs": { + "TagResponse": { + "properties": { + "color": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "project_id": { + "type": "string" + } + }, + "required": [ + "id", + "project_id", + "name" + ], + "type": "object" + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "author_avatar_url": { + "type": [ + "string", + "null" + ] + }, + "author_company": { + "type": [ + "string", + "null" + ] + }, + "author_email": { + "type": [ + "string", + "null" + ] + }, + "author_name": { + "type": "string" + }, + "author_title": { + "type": [ + "string", + "null" + ] + }, + "author_url": { + "type": [ + "string", + "null" + ] + }, + "content": { + "type": [ + "string", + "null" + ] + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "is_approved": { + "type": "boolean" + }, + "is_featured": { + "type": "boolean" + }, + "language": { + "type": [ + "string", + "null" + ] + }, + "project_id": { + "type": "string" + }, + "rating": { + "format": "int16", + "maximum": 32767, + "minimum": -32768, + "type": [ + "integer", + "null" + ] + }, + "sentiment": { + "type": [ + "string", + "null" + ] + }, + "sentiment_score": { + "format": "float", + "type": [ + "number", + "null" + ] + }, + "source": { + "type": [ + "string", + "null" + ] + }, + "source_id": { + "type": [ + "string", + "null" + ] + }, + "source_platform": { + "type": [ + "string", + "null" + ] + }, + "source_url": { + "type": [ + "string", + "null" + ] + }, + "tags": { + "items": { + "$ref": "#/$defs/TagResponse" + }, + "type": "array" + }, + "transcription": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "video_duration_seconds": { + "format": "int32", + "type": [ + "integer", + "null" + ] + }, + "video_thumbnail_url": { + "type": [ + "string", + "null" + ] + }, + "video_url": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "project_id", + "type", + "author_name", + "is_approved", + "is_featured", + "tags", + "created_at", + "updated_at" + ], + "title": "TestimonialResponse", + "type": "object" + } + } + }, + "description": "Success" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "User does not own this project" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Testimonial not found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal server error" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Error response" + } + }, + "summary": "Update testimonial" + } + }, + "/api/v1/testimonials/{id}/approve": { + "post": { + "operationId": "approve_testimonial", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$defs": { + "TagResponse": { + "properties": { + "color": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "project_id": { + "type": "string" + } + }, + "required": [ + "id", + "project_id", + "name" + ], + "type": "object" + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "author_avatar_url": { + "type": [ + "string", + "null" + ] + }, + "author_company": { + "type": [ + "string", + "null" + ] + }, + "author_email": { + "type": [ + "string", + "null" + ] + }, + "author_name": { + "type": "string" + }, + "author_title": { + "type": [ + "string", + "null" + ] + }, + "author_url": { + "type": [ + "string", + "null" + ] + }, + "content": { + "type": [ + "string", + "null" + ] + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "is_approved": { + "type": "boolean" + }, + "is_featured": { + "type": "boolean" + }, + "language": { + "type": [ + "string", + "null" + ] + }, + "project_id": { + "type": "string" + }, + "rating": { + "format": "int16", + "maximum": 32767, + "minimum": -32768, + "type": [ + "integer", + "null" + ] + }, + "sentiment": { + "type": [ + "string", + "null" + ] + }, + "sentiment_score": { + "format": "float", + "type": [ + "number", + "null" + ] + }, + "source": { + "type": [ + "string", + "null" + ] + }, + "source_id": { + "type": [ + "string", + "null" + ] + }, + "source_platform": { + "type": [ + "string", + "null" + ] + }, + "source_url": { + "type": [ + "string", + "null" + ] + }, + "tags": { + "items": { + "$ref": "#/$defs/TagResponse" + }, + "type": "array" + }, + "transcription": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "video_duration_seconds": { + "format": "int32", + "type": [ + "integer", + "null" + ] + }, + "video_thumbnail_url": { + "type": [ + "string", + "null" + ] + }, + "video_url": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "project_id", + "type", + "author_name", + "is_approved", + "is_featured", + "tags", + "created_at", + "updated_at" + ], + "title": "TestimonialResponse", + "type": "object" + } + } + }, + "description": "Success" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "User does not own this project" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Testimonial not found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal server error" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Error response" + } + }, + "summary": "Approve testimonial" + } + }, + "/api/v1/testimonials/{id}/feature": { + "post": { + "operationId": "feature_testimonial", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$defs": { + "TagResponse": { + "properties": { + "color": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "project_id": { + "type": "string" + } + }, + "required": [ + "id", + "project_id", + "name" + ], + "type": "object" + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "author_avatar_url": { + "type": [ + "string", + "null" + ] + }, + "author_company": { + "type": [ + "string", + "null" + ] + }, + "author_email": { + "type": [ + "string", + "null" + ] + }, + "author_name": { + "type": "string" + }, + "author_title": { + "type": [ + "string", + "null" + ] + }, + "author_url": { + "type": [ + "string", + "null" + ] + }, + "content": { + "type": [ + "string", + "null" + ] + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "is_approved": { + "type": "boolean" + }, + "is_featured": { + "type": "boolean" + }, + "language": { + "type": [ + "string", + "null" + ] + }, + "project_id": { + "type": "string" + }, + "rating": { + "format": "int16", + "maximum": 32767, + "minimum": -32768, + "type": [ + "integer", + "null" + ] + }, + "sentiment": { + "type": [ + "string", + "null" + ] + }, + "sentiment_score": { + "format": "float", + "type": [ + "number", + "null" + ] + }, + "source": { + "type": [ + "string", + "null" + ] + }, + "source_id": { + "type": [ + "string", + "null" + ] + }, + "source_platform": { + "type": [ + "string", + "null" + ] + }, + "source_url": { + "type": [ + "string", + "null" + ] + }, + "tags": { + "items": { + "$ref": "#/$defs/TagResponse" + }, + "type": "array" + }, + "transcription": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "video_duration_seconds": { + "format": "int32", + "type": [ + "integer", + "null" + ] + }, + "video_thumbnail_url": { + "type": [ + "string", + "null" + ] + }, + "video_url": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "project_id", + "type", + "author_name", + "is_approved", + "is_featured", + "tags", + "created_at", + "updated_at" + ], + "title": "TestimonialResponse", + "type": "object" + } + } + }, + "description": "Success" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "User does not own this project" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Testimonial not found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal server error" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Error response" + } + }, + "summary": "Feature testimonial" + } + }, + "/api/v1/testimonials/{id}/tags": { + "put": { + "operationId": "set_testimonial_tags", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$defs": { + "TagResponse": { + "properties": { + "color": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "project_id": { + "type": "string" + } + }, + "required": [ + "id", + "project_id", + "name" + ], + "type": "object" + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "tags": { + "items": { + "$ref": "#/$defs/TagResponse" + }, + "type": "array" + } + }, + "required": [ + "tags" + ], + "title": "TestimonialTagsResponse", + "type": "object" + } + } + }, + "description": "Success" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "User does not own this project" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Tag not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Tag name already exists in this project" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal server error" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Error response" + } + }, + "summary": "Set testimonial tags" + } + }, "/health": { "get": { "operationId": "health", diff --git a/src/api/v1/mod.rs b/src/api/v1/mod.rs index ab5411c..768f65c 100644 --- a/src/api/v1/mod.rs +++ b/src/api/v1/mod.rs @@ -1,3 +1,4 @@ pub mod auth; pub mod projects; +pub mod tags; pub mod testimonials; diff --git a/src/api/v1/tags/dto.rs b/src/api/v1/tags/dto.rs new file mode 100644 index 0000000..555f7db --- /dev/null +++ b/src/api/v1/tags/dto.rs @@ -0,0 +1,32 @@ +use rapina::schemars::{self, JsonSchema}; +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, JsonSchema)] +pub struct CreateTagRequest { + pub name: String, + pub color: Option, +} + +#[derive(Deserialize, JsonSchema)] +pub struct UpdateTagRequest { + pub name: Option, + pub color: Option, +} + +#[derive(Clone, Serialize, JsonSchema)] +pub struct TagResponse { + pub id: String, + pub project_id: String, + pub name: String, + pub color: Option, +} + +#[derive(Deserialize, JsonSchema)] +pub struct SetTestimonialTagsRequest { + pub tag_ids: Vec, +} + +#[derive(Serialize, JsonSchema)] +pub struct TestimonialTagsResponse { + pub tags: Vec, +} diff --git a/src/api/v1/tags/error.rs b/src/api/v1/tags/error.rs new file mode 100644 index 0000000..37a58f7 --- /dev/null +++ b/src/api/v1/tags/error.rs @@ -0,0 +1,55 @@ +use rapina::database::DbError; +use rapina::prelude::*; + +pub enum TagError { + DbError(DbError), + NotFound, + Forbidden, + NameTaken, +} + +impl IntoApiError for TagError { + fn into_api_error(self) -> Error { + match self { + TagError::DbError(e) => e.into_api_error(), + TagError::NotFound => Error::not_found("tag not found"), + TagError::Forbidden => Error::forbidden("you do not own this project"), + TagError::NameTaken => { + Error::conflict("a tag with this name already exists in this project") + } + } + } +} + +impl DocumentedError for TagError { + fn error_variants() -> Vec { + vec![ + ErrorVariant { + status: 404, + code: "NOT_FOUND", + description: "Tag not found", + }, + ErrorVariant { + status: 403, + code: "FORBIDDEN", + description: "User does not own this project", + }, + ErrorVariant { + status: 409, + code: "CONFLICT", + description: "Tag name already exists in this project", + }, + ErrorVariant { + status: 500, + code: "INTERNAL_ERROR", + description: "Internal server error", + }, + ] + } +} + +impl From for TagError { + fn from(e: DbError) -> Self { + TagError::DbError(e) + } +} diff --git a/src/api/v1/tags/handlers.rs b/src/api/v1/tags/handlers.rs new file mode 100644 index 0000000..e2eb910 --- /dev/null +++ b/src/api/v1/tags/handlers.rs @@ -0,0 +1,360 @@ +use rapina::database::{Db, DbError}; +use rapina::prelude::*; +use rapina::sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; +use uuid::Uuid; + +use crate::db::entities::project::{Column as ProjectColumn, Entity as Project}; +use crate::db::entities::tag::{ActiveModel, Column, Entity as Tag}; +use crate::db::entities::testimonial::{Column as TestimonialColumn, Entity as Testimonial}; +use crate::db::entities::testimonial_tag::{ + ActiveModel as TestimonialTagActiveModel, Column as TestimonialTagColumn, + Entity as TestimonialTag, +}; +use crate::db::entities::user::{Column as UserColumn, Entity as User}; + +use super::dto::{ + CreateTagRequest, SetTestimonialTagsRequest, TagResponse, TestimonialTagsResponse, + UpdateTagRequest, +}; +use super::error::TagError; + +fn to_response(tag: crate::db::entities::tag::Model, project_pid: &Uuid) -> TagResponse { + TagResponse { + id: tag.pid.to_string(), + project_id: project_pid.to_string(), + name: tag.name, + color: tag.color, + } +} + +async fn resolve_user_id(db: &Db, current_user: &CurrentUser) -> Result { + let pid = Uuid::parse_str(¤t_user.id) + .map_err(|_| Error::unauthorized("invalid user id in token"))?; + + let user = User::find() + .filter(UserColumn::Pid.eq(pid)) + .one(db.conn()) + .await + .map_err(DbError)? + .ok_or_else(|| Error::unauthorized("user not found"))?; + + Ok(user.id) +} + +pub fn to_tag_responses( + tags: Vec, + project_pid: &Uuid, +) -> Vec { + tags.into_iter() + .map(|t| to_response(t, project_pid)) + .collect() +} + +pub async fn load_tags_for_testimonial( + db: &Db, + testimonial_id: i32, + project_pid: &Uuid, +) -> Result> { + let links = TestimonialTag::find() + .filter(TestimonialTagColumn::TestimonialId.eq(testimonial_id)) + .all(db.conn()) + .await + .map_err(DbError)?; + + if links.is_empty() { + return Ok(vec![]); + } + + let tag_ids: Vec = links.iter().map(|l| l.tag_id).collect(); + let tags = Tag::find() + .filter(Column::Id.is_in(tag_ids)) + .all(db.conn()) + .await + .map_err(DbError)?; + + Ok(to_tag_responses(tags, project_pid)) +} + +pub async fn load_tags_for_testimonials( + db: &Db, + testimonial_ids: &[i32], + project_pid: &Uuid, +) -> Result>> { + use std::collections::HashMap; + + if testimonial_ids.is_empty() { + return Ok(HashMap::new()); + } + + let links = TestimonialTag::find() + .filter(TestimonialTagColumn::TestimonialId.is_in(testimonial_ids.to_vec())) + .all(db.conn()) + .await + .map_err(DbError)?; + + if links.is_empty() { + return Ok(HashMap::new()); + } + + let tag_ids: Vec = links.iter().map(|l| l.tag_id).collect(); + let tags = Tag::find() + .filter(Column::Id.is_in(tag_ids)) + .all(db.conn()) + .await + .map_err(DbError)?; + + let tag_map: HashMap = + tags.into_iter().map(|t| (t.id, t)).collect(); + + let mut result: HashMap> = HashMap::new(); + for link in links { + if let Some(tag) = tag_map.get(&link.tag_id) { + result + .entry(link.testimonial_id) + .or_default() + .push(to_response(tag.clone(), project_pid)); + } + } + + Ok(result) +} + +#[get("/api/v1/projects/:id/tags")] +#[errors(TagError)] +pub async fn list_tags( + id: Path, + db: Db, + current_user: CurrentUser, +) -> Result>> { + let user_id = resolve_user_id(&db, ¤t_user).await?; + let pid = Uuid::parse_str(&id.into_inner()).map_err(|_| TagError::NotFound.into_api_error())?; + + let project = Project::find() + .filter(ProjectColumn::Pid.eq(pid)) + .one(db.conn()) + .await + .map_err(DbError)? + .ok_or_else(|| TagError::NotFound.into_api_error())?; + + if project.user_id != user_id { + return Err(TagError::Forbidden.into_api_error()); + } + + let tags = Tag::find() + .filter(Column::ProjectId.eq(project.id)) + .all(db.conn()) + .await + .map_err(DbError)?; + + let response = to_tag_responses(tags, &project.pid); + Ok(Json(response)) +} + +#[post("/api/v1/projects/:id/tags")] +#[errors(TagError)] +pub async fn create_tag( + id: Path, + db: Db, + current_user: CurrentUser, + body: Json, +) -> Result<(StatusCode, Json)> { + let user_id = resolve_user_id(&db, ¤t_user).await?; + let pid = Uuid::parse_str(&id.into_inner()).map_err(|_| TagError::NotFound.into_api_error())?; + + let project = Project::find() + .filter(ProjectColumn::Pid.eq(pid)) + .one(db.conn()) + .await + .map_err(DbError)? + .ok_or_else(|| TagError::NotFound.into_api_error())?; + + if project.user_id != user_id { + return Err(TagError::Forbidden.into_api_error()); + } + + let req = body.into_inner(); + + let existing = Tag::find() + .filter(Column::ProjectId.eq(project.id)) + .filter(Column::Name.eq(&req.name)) + .one(db.conn()) + .await + .map_err(DbError)?; + + if existing.is_some() { + return Err(TagError::NameTaken.into_api_error()); + } + + let new_tag = ActiveModel { + pid: Set(Uuid::new_v4()), + project_id: Set(project.id), + name: Set(req.name), + color: Set(req.color), + ..Default::default() + }; + + let tag = new_tag.insert(db.conn()).await.map_err(DbError)?; + + Ok((StatusCode::CREATED, Json(to_response(tag, &project.pid)))) +} + +#[put("/api/v1/tags/:id")] +#[errors(TagError)] +pub async fn update_tag( + id: Path, + db: Db, + current_user: CurrentUser, + body: Json, +) -> Result> { + let user_id = resolve_user_id(&db, ¤t_user).await?; + let pid = Uuid::parse_str(&id.into_inner()).map_err(|_| TagError::NotFound.into_api_error())?; + + let tag = Tag::find() + .filter(Column::Pid.eq(pid)) + .one(db.conn()) + .await + .map_err(DbError)? + .ok_or_else(|| TagError::NotFound.into_api_error())?; + + let project = Project::find_by_id(tag.project_id) + .one(db.conn()) + .await + .map_err(DbError)? + .ok_or_else(|| TagError::NotFound.into_api_error())?; + + if project.user_id != user_id { + return Err(TagError::Forbidden.into_api_error()); + } + + let req = body.into_inner(); + + if let Some(ref name) = req.name { + let name_taken = Tag::find() + .filter(Column::ProjectId.eq(project.id)) + .filter(Column::Name.eq(name)) + .filter(Column::Id.ne(tag.id)) + .one(db.conn()) + .await + .map_err(DbError)?; + + if name_taken.is_some() { + return Err(TagError::NameTaken.into_api_error()); + } + } + + let mut active: ActiveModel = tag.into(); + + if let Some(name) = req.name { + active.name = Set(name); + } + if let Some(color) = req.color { + active.color = Set(Some(color)); + } + + let updated = active.update(db.conn()).await.map_err(DbError)?; + + Ok(Json(to_response(updated, &project.pid))) +} + +#[delete("/api/v1/tags/:id")] +#[errors(TagError)] +pub async fn delete_tag(id: Path, db: Db, current_user: CurrentUser) -> Result { + let user_id = resolve_user_id(&db, ¤t_user).await?; + let pid = Uuid::parse_str(&id.into_inner()).map_err(|_| TagError::NotFound.into_api_error())?; + + let tag = Tag::find() + .filter(Column::Pid.eq(pid)) + .one(db.conn()) + .await + .map_err(DbError)? + .ok_or_else(|| TagError::NotFound.into_api_error())?; + + let project = Project::find_by_id(tag.project_id) + .one(db.conn()) + .await + .map_err(DbError)? + .ok_or_else(|| TagError::NotFound.into_api_error())?; + + if project.user_id != user_id { + return Err(TagError::Forbidden.into_api_error()); + } + + Tag::delete_by_id(tag.id) + .exec(db.conn()) + .await + .map_err(DbError)?; + + Ok(StatusCode::NO_CONTENT) +} + +#[put("/api/v1/testimonials/:id/tags")] +#[errors(TagError)] +pub async fn set_testimonial_tags( + id: Path, + db: Db, + current_user: CurrentUser, + body: Json, +) -> Result> { + let user_id = resolve_user_id(&db, ¤t_user).await?; + let pid = Uuid::parse_str(&id.into_inner()).map_err(|_| TagError::NotFound.into_api_error())?; + + let testimonial = Testimonial::find() + .filter(TestimonialColumn::Pid.eq(pid)) + .one(db.conn()) + .await + .map_err(DbError)? + .ok_or_else(|| TagError::NotFound.into_api_error())?; + + let project = Project::find_by_id(testimonial.project_id) + .one(db.conn()) + .await + .map_err(DbError)? + .ok_or_else(|| TagError::NotFound.into_api_error())?; + + if project.user_id != user_id { + return Err(TagError::Forbidden.into_api_error()); + } + + let req = body.into_inner(); + + // Resolve tag pids to internal ids, verify all belong to this project + let mut tag_models = Vec::new(); + for tag_pid_str in &req.tag_ids { + let tag_pid = + Uuid::parse_str(tag_pid_str).map_err(|_| TagError::NotFound.into_api_error())?; + + let tag = Tag::find() + .filter(Column::Pid.eq(tag_pid)) + .one(db.conn()) + .await + .map_err(DbError)? + .ok_or_else(|| TagError::NotFound.into_api_error())?; + + if tag.project_id != project.id { + return Err(TagError::Forbidden.into_api_error()); + } + + tag_models.push(tag); + } + + // Delete existing testimonial_tags + TestimonialTag::delete_many() + .filter(TestimonialTagColumn::TestimonialId.eq(testimonial.id)) + .exec(db.conn()) + .await + .map_err(DbError)?; + + // Insert new ones + for tag in &tag_models { + let link = TestimonialTagActiveModel { + testimonial_id: Set(testimonial.id), + tag_id: Set(tag.id), + }; + link.insert(db.conn()).await.map_err(DbError)?; + } + + let response_tags = to_tag_responses(tag_models, &project.pid); + Ok(Json(TestimonialTagsResponse { + tags: response_tags, + })) +} diff --git a/src/api/v1/tags/mod.rs b/src/api/v1/tags/mod.rs new file mode 100644 index 0000000..00b9e72 --- /dev/null +++ b/src/api/v1/tags/mod.rs @@ -0,0 +1,22 @@ +pub mod dto; +pub mod error; +pub mod handlers; + +use handlers::*; +use rapina::prelude::*; + +pub fn project_routes() -> Router { + Router::new() + .get("/:id/tags", list_tags) + .post("/:id/tags", create_tag) +} + +pub fn routes() -> Router { + Router::new() + .put("/:id", update_tag) + .delete("/:id", delete_tag) +} + +pub fn testimonial_tag_routes() -> Router { + Router::new().put("/:id/tags", set_testimonial_tags) +} diff --git a/src/api/v1/testimonials/dto.rs b/src/api/v1/testimonials/dto.rs index 1896509..149c309 100644 --- a/src/api/v1/testimonials/dto.rs +++ b/src/api/v1/testimonials/dto.rs @@ -1,6 +1,8 @@ use rapina::schemars::{self, JsonSchema}; use serde::{Deserialize, Serialize}; +use crate::api::v1::tags::dto::TagResponse; + #[derive(Deserialize, JsonSchema)] pub struct CreateTestimonialRequest { pub author_name: String, @@ -80,6 +82,7 @@ pub struct TestimonialResponse { pub language: Option, pub is_approved: bool, pub is_featured: bool, + pub tags: Vec, pub created_at: String, pub updated_at: String, } diff --git a/src/api/v1/testimonials/handlers.rs b/src/api/v1/testimonials/handlers.rs index 76ae1dc..6f78325 100644 --- a/src/api/v1/testimonials/handlers.rs +++ b/src/api/v1/testimonials/handlers.rs @@ -3,6 +3,8 @@ use rapina::prelude::*; use rapina::sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; use uuid::Uuid; +use crate::api::v1::tags::dto::TagResponse; +use crate::api::v1::tags::handlers::{load_tags_for_testimonial, load_tags_for_testimonials}; use crate::db::entities::project::{Column as ProjectColumn, Entity as Project}; use crate::db::entities::testimonial::{ActiveModel, Column, Entity as Testimonial}; use crate::db::entities::user::{Column as UserColumn, Entity as User}; @@ -15,6 +17,7 @@ use super::error::TestimonialError; fn to_response( t: crate::db::entities::testimonial::Model, project_pid: &Uuid, + tags: Vec, ) -> TestimonialResponse { TestimonialResponse { id: t.pid.to_string(), @@ -41,6 +44,7 @@ fn to_response( language: t.language, is_approved: t.is_approved, is_featured: t.is_featured, + tags, created_at: t.created_at.to_rfc3339(), updated_at: t.updated_at.to_rfc3339(), } @@ -95,9 +99,15 @@ pub async fn list_testimonials( let testimonials = q.all(db.conn()).await.map_err(DbError)?; + let testimonial_ids: Vec = testimonials.iter().map(|t| t.id).collect(); + let mut tags_map = load_tags_for_testimonials(&db, &testimonial_ids, &project.pid).await?; + let response: Vec = testimonials .into_iter() - .map(|t| to_response(t, &project.pid)) + .map(|t| { + let tags = tags_map.remove(&t.id).unwrap_or_default(); + to_response(t, &project.pid, tags) + }) .collect(); Ok(Json(response)) @@ -158,7 +168,7 @@ pub async fn create_testimonial( Ok(( StatusCode::CREATED, - Json(to_response(testimonial, &project.pid)), + Json(to_response(testimonial, &project.pid, vec![])), )) } @@ -190,7 +200,8 @@ pub async fn get_testimonial( return Err(TestimonialError::Forbidden.into_api_error()); } - Ok(Json(to_response(testimonial, &project.pid))) + let tags = load_tags_for_testimonial(&db, testimonial.id, &project.pid).await?; + Ok(Json(to_response(testimonial, &project.pid, tags))) } #[put("/api/v1/testimonials/:id")] @@ -294,7 +305,8 @@ pub async fn update_testimonial( let updated = active.update(db.conn()).await.map_err(DbError)?; - Ok(Json(to_response(updated, &project.pid))) + let tags = load_tags_for_testimonial(&db, updated.id, &project.pid).await?; + Ok(Json(to_response(updated, &project.pid, tags))) } #[delete("/api/v1/testimonials/:id")] @@ -362,12 +374,14 @@ pub async fn approve_testimonial( } let new_value = !testimonial.is_approved; + let testimonial_id = testimonial.id; let mut active: ActiveModel = testimonial.into(); active.is_approved = Set(new_value); let updated = active.update(db.conn()).await.map_err(DbError)?; - Ok(Json(to_response(updated, &project.pid))) + let tags = load_tags_for_testimonial(&db, testimonial_id, &project.pid).await?; + Ok(Json(to_response(updated, &project.pid, tags))) } #[post("/api/v1/testimonials/:id/feature")] @@ -399,10 +413,12 @@ pub async fn feature_testimonial( } let new_value = !testimonial.is_featured; + let testimonial_id = testimonial.id; let mut active: ActiveModel = testimonial.into(); active.is_featured = Set(new_value); let updated = active.update(db.conn()).await.map_err(DbError)?; - Ok(Json(to_response(updated, &project.pid))) + let tags = load_tags_for_testimonial(&db, testimonial_id, &project.pid).await?; + Ok(Json(to_response(updated, &project.pid, tags))) } diff --git a/src/db/entities/mod.rs b/src/db/entities/mod.rs index a5f1a63..0a92b5b 100644 --- a/src/db/entities/mod.rs +++ b/src/db/entities/mod.rs @@ -1,3 +1,5 @@ pub mod project; +pub mod tag; pub mod testimonial; +pub mod testimonial_tag; pub mod user; diff --git a/src/db/entities/tag.rs b/src/db/entities/tag.rs new file mode 100644 index 0000000..498cd82 --- /dev/null +++ b/src/db/entities/tag.rs @@ -0,0 +1,20 @@ +use rapina::sea_orm; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "tags")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + #[sea_orm(unique)] + pub pid: Uuid, + pub project_id: i32, + pub name: String, + pub color: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/db/entities/testimonial_tag.rs b/src/db/entities/testimonial_tag.rs new file mode 100644 index 0000000..1c1b632 --- /dev/null +++ b/src/db/entities/testimonial_tag.rs @@ -0,0 +1,17 @@ +use rapina::sea_orm; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "testimonial_tags")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub testimonial_id: i32, + #[sea_orm(primary_key, auto_increment = false)] + pub tag_id: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/main.rs b/src/main.rs index 71cb886..b376a5a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ use rapina::schemars; use reeverb::api::v1::auth; use reeverb::api::v1::projects; +use reeverb::api::v1::tags; use reeverb::api::v1::testimonials; #[derive(Clone, Config)] @@ -48,7 +49,10 @@ async fn main() -> std::io::Result<()> { .group("/api/v1/auth", auth::routes()) .group("/api/v1/projects", projects::routes()) .group("/api/v1/projects", testimonials::project_routes()) - .group("/api/v1/testimonials", testimonials::routes()); + .group("/api/v1/projects", tags::project_routes()) + .group("/api/v1/tags", tags::routes()) + .group("/api/v1/testimonials", testimonials::routes()) + .group("/api/v1/testimonials", tags::testimonial_tag_routes()); let mut app = Rapina::new() .with_tracing(TracingConfig::new()) diff --git a/tests/tags.rs b/tests/tags.rs new file mode 100644 index 0000000..1ffd46e --- /dev/null +++ b/tests/tags.rs @@ -0,0 +1,429 @@ +use rapina::auth::{AuthMiddleware, PublicRoutes}; +use rapina::database::DatabaseConfig; +use rapina::prelude::*; +use rapina::testing::TestClient; +use serde_json::json; +use tokio::sync::OnceCell; +use uuid::Uuid; + +use reeverb::api::v1::auth; +use reeverb::api::v1::projects; +use reeverb::api::v1::tags; +use reeverb::api::v1::testimonials; +use reeverb::db::migrations::Migrator; + +static MIGRATIONS: OnceCell<()> = OnceCell::const_new(); + +fn database_url() -> String { + dotenvy::dotenv().ok(); + std::env::var("DATABASE_URL").expect("DATABASE_URL must be set for integration tests") +} + +async fn run_migrations_once() { + MIGRATIONS + .get_or_init(|| async { + let config = DatabaseConfig::new(database_url()); + let conn = config.connect().await.expect("failed to connect"); + + use rapina::sea_orm_migration::MigratorTrait; + Migrator::up(&conn, None) + .await + .expect("failed to run migrations"); + }) + .await; +} + +async fn setup() -> TestClient { + run_migrations_once().await; + + let auth_config = AuthConfig::new("test-secret", 3600); + + let mut public_routes = PublicRoutes::new(); + for (method, path) in auth::PUBLIC_ROUTES { + public_routes.add(method, path); + } + + let auth_middleware = AuthMiddleware::with_public_routes(auth_config.clone(), public_routes); + + let router = Router::new() + .group("/api/v1/auth", auth::routes()) + .group("/api/v1/projects", projects::routes()) + .group("/api/v1/projects", testimonials::project_routes()) + .group("/api/v1/projects", tags::project_routes()) + .group("/api/v1/tags", tags::routes()) + .group("/api/v1/testimonials", testimonials::routes()) + .group("/api/v1/testimonials", tags::testimonial_tag_routes()); + + let app = Rapina::new() + .with_introspection(false) + .state(auth_config) + .middleware(auth_middleware) + .with_database(DatabaseConfig::new(database_url())) + .await + .expect("failed to connect to test database") + .router(router); + + TestClient::new(app).await +} + +fn unique_email() -> String { + format!("test-{}@example.com", Uuid::new_v4()) +} + +async fn register_and_get_token(client: &TestClient) -> String { + let email = unique_email(); + let res = client + .post("/api/v1/auth/register") + .json(&json!({ + "email": email, + "password": "password123", + "name": "Test User" + })) + .send() + .await; + + let body: serde_json::Value = res.json(); + body["token"].as_str().unwrap().to_string() +} + +fn unique_slug() -> String { + format!("project-{}", Uuid::new_v4()) +} + +async fn create_project(client: &TestClient, token: &str) -> String { + let slug = unique_slug(); + let res = client + .post("/api/v1/projects") + .header("Authorization", &format!("Bearer {}", token)) + .json(&json!({ "name": "Test Project", "slug": slug })) + .send() + .await; + + let body: serde_json::Value = res.json(); + body["id"].as_str().unwrap().to_string() +} + +async fn create_test_testimonial(client: &TestClient, token: &str, project_pid: &str) -> String { + let res = client + .post(&format!("/api/v1/projects/{}/testimonials", project_pid)) + .header("Authorization", &format!("Bearer {}", token)) + .json(&json!({ + "author_name": "Jane Doe", + "content": "Great product!", + "rating": 5, + "author_email": "jane@example.com" + })) + .send() + .await; + + assert_eq!(res.status(), StatusCode::CREATED); + let body: serde_json::Value = res.json(); + body["id"].as_str().unwrap().to_string() +} + +async fn create_test_tag( + client: &TestClient, + token: &str, + project_pid: &str, + name: &str, + color: Option<&str>, +) -> String { + let mut payload = json!({ "name": name }); + if let Some(c) = color { + payload["color"] = json!(c); + } + + let res = client + .post(&format!("/api/v1/projects/{}/tags", project_pid)) + .header("Authorization", &format!("Bearer {}", token)) + .json(&payload) + .send() + .await; + + assert_eq!(res.status(), StatusCode::CREATED); + let body: serde_json::Value = res.json(); + body["id"].as_str().unwrap().to_string() +} + +#[tokio::test] +async fn create_tag_returns_201() { + let client = setup().await; + let token = register_and_get_token(&client).await; + let project_pid = create_project(&client, &token).await; + + let res = client + .post(&format!("/api/v1/projects/{}/tags", project_pid)) + .header("Authorization", &format!("Bearer {}", token)) + .json(&json!({ "name": "bug", "color": "#ff0000" })) + .send() + .await; + + assert_eq!(res.status(), StatusCode::CREATED); + + let body: serde_json::Value = res.json(); + assert_eq!(body["name"], "bug"); + assert_eq!(body["color"], "#ff0000"); + assert_eq!(body["project_id"], project_pid); + assert!(body["id"].is_string()); +} + +#[tokio::test] +async fn create_duplicate_name_returns_409() { + let client = setup().await; + let token = register_and_get_token(&client).await; + let project_pid = create_project(&client, &token).await; + + create_test_tag(&client, &token, &project_pid, "duplicate", None).await; + + let res = client + .post(&format!("/api/v1/projects/{}/tags", project_pid)) + .header("Authorization", &format!("Bearer {}", token)) + .json(&json!({ "name": "duplicate" })) + .send() + .await; + + assert_eq!(res.status(), StatusCode::CONFLICT); +} + +#[tokio::test] +async fn list_tags_for_project() { + let client = setup().await; + let token = register_and_get_token(&client).await; + let project_pid = create_project(&client, &token).await; + + create_test_tag(&client, &token, &project_pid, "feature", Some("#00ff00")).await; + + let res = client + .get(&format!("/api/v1/projects/{}/tags", project_pid)) + .header("Authorization", &format!("Bearer {}", token)) + .send() + .await; + + assert_eq!(res.status(), StatusCode::OK); + + let body: Vec = res.json(); + assert!(!body.is_empty()); + let found = body.iter().any(|t| t["name"] == "feature"); + assert!(found); +} + +#[tokio::test] +async fn update_tag_partial() { + let client = setup().await; + let token = register_and_get_token(&client).await; + let project_pid = create_project(&client, &token).await; + let tag_pid = create_test_tag(&client, &token, &project_pid, "old-name", Some("#000000")).await; + + let res = client + .put(&format!("/api/v1/tags/{}", tag_pid)) + .header("Authorization", &format!("Bearer {}", token)) + .json(&json!({ "color": "#ffffff" })) + .send() + .await; + + assert_eq!(res.status(), StatusCode::OK); + + let body: serde_json::Value = res.json(); + assert_eq!(body["name"], "old-name"); + assert_eq!(body["color"], "#ffffff"); +} + +#[tokio::test] +async fn update_tag_name_to_existing_returns_409() { + let client = setup().await; + let token = register_and_get_token(&client).await; + let project_pid = create_project(&client, &token).await; + + create_test_tag(&client, &token, &project_pid, "taken-name", None).await; + let tag_pid = create_test_tag(&client, &token, &project_pid, "other-name", None).await; + + let res = client + .put(&format!("/api/v1/tags/{}", tag_pid)) + .header("Authorization", &format!("Bearer {}", token)) + .json(&json!({ "name": "taken-name" })) + .send() + .await; + + assert_eq!(res.status(), StatusCode::CONFLICT); +} + +#[tokio::test] +async fn delete_tag_returns_204() { + let client = setup().await; + let token = register_and_get_token(&client).await; + let project_pid = create_project(&client, &token).await; + let tag_pid = create_test_tag(&client, &token, &project_pid, "to-delete", None).await; + + let res = client + .delete(&format!("/api/v1/tags/{}", tag_pid)) + .header("Authorization", &format!("Bearer {}", token)) + .send() + .await; + + assert_eq!(res.status(), StatusCode::NO_CONTENT); + + // Verify it's gone from list + let res = client + .get(&format!("/api/v1/projects/{}/tags", project_pid)) + .header("Authorization", &format!("Bearer {}", token)) + .send() + .await; + + let body: Vec = res.json(); + let found = body.iter().any(|t| t["id"] == tag_pid); + assert!(!found); +} + +#[tokio::test] +async fn set_testimonial_tags() { + let client = setup().await; + let token = register_and_get_token(&client).await; + let project_pid = create_project(&client, &token).await; + let testimonial_pid = create_test_testimonial(&client, &token, &project_pid).await; + let tag1 = create_test_tag(&client, &token, &project_pid, "tag-a", None).await; + let tag2 = create_test_tag(&client, &token, &project_pid, "tag-b", None).await; + + let res = client + .put(&format!("/api/v1/testimonials/{}/tags", testimonial_pid)) + .header("Authorization", &format!("Bearer {}", token)) + .json(&json!({ "tag_ids": [tag1, tag2] })) + .send() + .await; + + assert_eq!(res.status(), StatusCode::OK); + + let body: serde_json::Value = res.json(); + let tags = body["tags"].as_array().unwrap(); + assert_eq!(tags.len(), 2); +} + +#[tokio::test] +async fn set_testimonial_tags_empty_clears() { + let client = setup().await; + let token = register_and_get_token(&client).await; + let project_pid = create_project(&client, &token).await; + let testimonial_pid = create_test_testimonial(&client, &token, &project_pid).await; + let tag1 = create_test_tag(&client, &token, &project_pid, "clear-me", None).await; + + // Set one tag + client + .put(&format!("/api/v1/testimonials/{}/tags", testimonial_pid)) + .header("Authorization", &format!("Bearer {}", token)) + .json(&json!({ "tag_ids": [tag1] })) + .send() + .await; + + // Clear all tags + let res = client + .put(&format!("/api/v1/testimonials/{}/tags", testimonial_pid)) + .header("Authorization", &format!("Bearer {}", token)) + .json(&json!({ "tag_ids": [] })) + .send() + .await; + + assert_eq!(res.status(), StatusCode::OK); + + let body: serde_json::Value = res.json(); + let tags = body["tags"].as_array().unwrap(); + assert!(tags.is_empty()); +} + +#[tokio::test] +async fn set_tags_from_different_project_fails() { + let client = setup().await; + let token = register_and_get_token(&client).await; + let project1 = create_project(&client, &token).await; + let project2 = create_project(&client, &token).await; + let testimonial_pid = create_test_testimonial(&client, &token, &project1).await; + let foreign_tag = create_test_tag(&client, &token, &project2, "foreign", None).await; + + let res = client + .put(&format!("/api/v1/testimonials/{}/tags", testimonial_pid)) + .header("Authorization", &format!("Bearer {}", token)) + .json(&json!({ "tag_ids": [foreign_tag] })) + .send() + .await; + + assert_eq!(res.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn testimonial_get_includes_tags() { + let client = setup().await; + let token = register_and_get_token(&client).await; + let project_pid = create_project(&client, &token).await; + let testimonial_pid = create_test_testimonial(&client, &token, &project_pid).await; + let tag_pid = create_test_tag(&client, &token, &project_pid, "included", Some("#abcdef")).await; + + client + .put(&format!("/api/v1/testimonials/{}/tags", testimonial_pid)) + .header("Authorization", &format!("Bearer {}", token)) + .json(&json!({ "tag_ids": [tag_pid] })) + .send() + .await; + + let res = client + .get(&format!("/api/v1/testimonials/{}", testimonial_pid)) + .header("Authorization", &format!("Bearer {}", token)) + .send() + .await; + + assert_eq!(res.status(), StatusCode::OK); + + let body: serde_json::Value = res.json(); + let tags = body["tags"].as_array().unwrap(); + assert_eq!(tags.len(), 1); + assert_eq!(tags[0]["name"], "included"); + assert_eq!(tags[0]["color"], "#abcdef"); +} + +#[tokio::test] +async fn ownership_enforcement_returns_403() { + let client = setup().await; + let token_owner = register_and_get_token(&client).await; + let token_other = register_and_get_token(&client).await; + let project_pid = create_project(&client, &token_owner).await; + let tag_pid = create_test_tag(&client, &token_owner, &project_pid, "owned", None).await; + + // List tags + let res = client + .get(&format!("/api/v1/projects/{}/tags", project_pid)) + .header("Authorization", &format!("Bearer {}", token_other)) + .send() + .await; + assert_eq!(res.status(), StatusCode::FORBIDDEN); + + // Create tag + let res = client + .post(&format!("/api/v1/projects/{}/tags", project_pid)) + .header("Authorization", &format!("Bearer {}", token_other)) + .json(&json!({ "name": "hacked" })) + .send() + .await; + assert_eq!(res.status(), StatusCode::FORBIDDEN); + + // Update tag + let res = client + .put(&format!("/api/v1/tags/{}", tag_pid)) + .header("Authorization", &format!("Bearer {}", token_other)) + .json(&json!({ "name": "hacked" })) + .send() + .await; + assert_eq!(res.status(), StatusCode::FORBIDDEN); + + // Delete tag + let res = client + .delete(&format!("/api/v1/tags/{}", tag_pid)) + .header("Authorization", &format!("Bearer {}", token_other)) + .send() + .await; + assert_eq!(res.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn tags_without_token_returns_401() { + let client = setup().await; + + let res = client.get("/api/v1/tags/some-id").send().await; + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); +}