Files
b2b/app/api/openapi.json
2026-03-11 14:17:26 +01:00

1396 lines
40 KiB
JSON

{
"openapi": "3.0.3",
"info": {
"title": "b2b API",
"description": "Authentication, user management, and repository time tracking API",
"version": "1.0.0",
"contact": {
"name": "API Support",
"email": "support@example.com"
}
},
"servers": [
{
"url": "http://localhost:3000",
"description": "Development server"
}
],
"tags": [
{
"name": "Health",
"description": "Health check endpoints"
},
{
"name": "Auth",
"description": "Authentication endpoints (under /api/v1/public/auth)"
},
{
"name": "Languages",
"description": "Language and translation endpoints"
},
{
"name": "Repo",
"description": "Repository time tracking data endpoints (under /api/v1/restricted/repo, requires authentication)"
},
{
"name": "Admin",
"description": "Admin-only endpoints"
},
{
"name": "Settings",
"description": "Application settings and configuration endpoints"
}
],
"paths": {
"/health": {
"get": {
"tags": ["Health"],
"summary": "Health check",
"description": "Returns the health status of the application",
"operationId": "getHealth",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "ok"
},
"app": {
"type": "string",
"example": "b2b"
},
"version": {
"type": "string",
"example": "1.0.0"
}
}
}
}
}
}
}
}
},
"/api/v1/langs": {
"get": {
"tags": ["Languages"],
"summary": "Get active languages",
"description": "Returns a list of all active languages",
"operationId": "getLanguages",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Language"
}
}
}
}
}
}
}
},
"/api/v1/translations": {
"get": {
"tags": ["Languages"],
"summary": "Get translations",
"description": "Returns translations from cache. Supports filtering by lang_id, scope, and components.",
"operationId": "getTranslations",
"parameters": [
{
"name": "lang_id",
"in": "query",
"description": "Filter by language ID",
"required": false,
"schema": {
"type": "integer"
}
},
{
"name": "scope",
"in": "query",
"description": "Filter by scope (e.g., 'be', 'frontend')",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "components",
"in": "query",
"description": "Filter by component name",
"required": false,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success"
},
"translations": {
"type": "object",
"description": "Translation data keyed by language ID, scope, component, and key"
}
}
}
}
}
},
"400": {
"description": "Invalid request parameters",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/api/v1/translations/reload": {
"get": {
"tags": ["Languages"],
"summary": "Reload translations",
"description": "Reloads translations from the database into the cache",
"operationId": "reloadTranslations",
"responses": {
"200": {
"description": "Translations reloaded successfully",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success"
},
"message": {
"type": "string",
"example": "Translations reloaded successfully"
}
}
}
}
}
},
"500": {
"description": "Failed to reload translations",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/api/v1/public/auth/login": {
"post": {
"tags": ["Auth"],
"summary": "User login",
"description": "Authenticate a user with email and password. Sets HTTPOnly cookies (access_token, refresh_token, is_authenticated) on success.",
"operationId": "login",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LoginRequest"
}
}
}
},
"responses": {
"200": {
"description": "Login successful",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AuthResponse"
}
}
},
"headers": {
"Set-Cookie": {
"schema": {
"type": "string"
},
"description": "HTTPOnly cookies: access_token, refresh_token (opaque), is_authenticated (non-HTTPOnly flag)"
}
}
},
"400": {
"description": "Invalid request body or missing fields",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Invalid credentials",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Account inactive or email not verified",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/api/v1/public/auth/register": {
"post": {
"tags": ["Auth"],
"summary": "User registration",
"description": "Register a new user account. Sends a verification email. first_name and last_name are required.",
"operationId": "register",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RegisterRequest"
}
}
}
},
"responses": {
"201": {
"description": "Registration successful",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"message": {
"type": "string",
"example": "registration successful, please verify your email"
}
}
}
}
}
},
"400": {
"description": "Invalid request, missing required fields, or invalid password format",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "Email already exists",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/api/v1/public/auth/complete-registration": {
"post": {
"tags": ["Auth"],
"summary": "Complete registration",
"description": "Complete registration after email verification using the token sent by email. Sets auth cookies on success.",
"operationId": "completeRegistration",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CompleteRegistrationRequest"
}
}
}
},
"responses": {
"201": {
"description": "Registration completed successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AuthResponse"
}
}
},
"headers": {
"Set-Cookie": {
"schema": {
"type": "string"
},
"description": "HTTPOnly cookies: access_token, refresh_token, is_authenticated"
}
}
},
"400": {
"description": "Invalid or expired token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/api/v1/public/auth/forgot-password": {
"post": {
"tags": ["Auth"],
"summary": "Request password reset",
"description": "Request a password reset email. Always returns success to prevent email enumeration.",
"operationId": "forgotPassword",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["email"],
"properties": {
"email": {
"type": "string",
"format": "email",
"description": "User's email address"
}
}
}
}
}
},
"responses": {
"200": {
"description": "Password reset email sent if account exists",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"message": {
"type": "string",
"example": "if an account with that email exists, a password reset link has been sent"
}
}
}
}
}
},
"400": {
"description": "Invalid request or missing email",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/api/v1/public/auth/reset-password": {
"post": {
"tags": ["Auth"],
"summary": "Reset password",
"description": "Reset password using reset token from email. Also revokes all existing refresh tokens for the user.",
"operationId": "resetPassword",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ResetPasswordRequest"
}
}
}
},
"responses": {
"200": {
"description": "Password reset successfully",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"message": {
"type": "string",
"example": "password reset successfully"
}
}
}
}
}
},
"400": {
"description": "Invalid or expired token, or invalid password format",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/api/v1/public/auth/logout": {
"post": {
"tags": ["Auth"],
"summary": "User logout",
"description": "Revokes the refresh token from the database and clears all authentication cookies.",
"operationId": "logout",
"responses": {
"200": {
"description": "Logout successful",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"message": {
"type": "string",
"example": "logged out successfully"
}
}
}
}
}
}
}
}
},
"/api/v1/public/auth/refresh": {
"post": {
"tags": ["Auth"],
"summary": "Refresh access token",
"description": "Get a new access token using the refresh token. The refresh token is read from the HTTPOnly cookie first, then from the request body as fallback. Rotates the refresh token on success.",
"operationId": "refreshToken",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"refresh_token": {
"type": "string",
"description": "Opaque refresh token (fallback if cookie not available)"
}
}
}
}
}
},
"responses": {
"200": {
"description": "Token refreshed successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AuthResponse"
}
}
},
"headers": {
"Set-Cookie": {
"schema": {
"type": "string"
},
"description": "Rotated HTTPOnly cookies: access_token, refresh_token, is_authenticated"
}
}
},
"400": {
"description": "Refresh token required",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Invalid or expired refresh token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/api/v1/public/auth/me": {
"get": {
"tags": ["Auth"],
"summary": "Get current user",
"description": "Returns the currently authenticated user's session information. Requires authentication via cookie.",
"operationId": "getMe",
"security": [
{
"CookieAuth": []
}
],
"responses": {
"200": {
"description": "Current user info",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"user": {
"$ref": "#/components/schemas/UserSession"
}
}
}
}
}
},
"401": {
"description": "Not authenticated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/api/v1/public/auth/google": {
"get": {
"tags": ["Auth"],
"summary": "Google OAuth2 login",
"description": "Redirects the user to Google's OAuth2 consent page. Sets a short-lived oauth_state cookie for CSRF protection.",
"operationId": "googleLogin",
"responses": {
"302": {
"description": "Redirect to Google OAuth2 consent page",
"headers": {
"Location": {
"schema": {
"type": "string"
},
"description": "Google OAuth2 authorization URL"
},
"Set-Cookie": {
"schema": {
"type": "string"
},
"description": "HTTPOnly oauth_state cookie for CSRF protection (10 min expiry)"
}
}
},
"500": {
"description": "Failed to generate OAuth state",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/api/v1/public/auth/google/callback": {
"get": {
"tags": ["Auth"],
"summary": "Google OAuth2 callback",
"description": "Handles the OAuth2 callback from Google. Validates state, exchanges code for tokens, creates or updates user, sets auth cookies, and redirects to the app.",
"operationId": "googleCallback",
"parameters": [
{
"name": "code",
"in": "query",
"description": "Authorization code from Google",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "state",
"in": "query",
"description": "State token for CSRF validation",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"302": {
"description": "Redirect to app after successful authentication",
"headers": {
"Location": {
"schema": {
"type": "string"
},
"description": "Redirect to /{lang} (user's preferred language)"
},
"Set-Cookie": {
"schema": {
"type": "string"
},
"description": "HTTPOnly cookies: access_token, refresh_token, is_authenticated"
}
}
},
"400": {
"description": "Invalid state (CSRF) or missing authorization code",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Google OAuth callback processing error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/api/v1/restricted/repo/get-repos": {
"get": {
"tags": ["Repo"],
"summary": "Get accessible repositories",
"description": "Returns a list of repository IDs that the authenticated user has access to.",
"operationId": "getRepos",
"security": [
{
"CookieAuth": []
}
],
"responses": {
"200": {
"description": "List of repository IDs",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"type": "integer",
"format": "uint"
},
"example": [1, 2, 5]
}
}
}
},
"400": {
"description": "Invalid user session",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Not authenticated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/api/v1/restricted/repo/get-years": {
"get": {
"tags": ["Repo"],
"summary": "Get available years for a repository",
"description": "Returns a list of years for which tracked time data exists in the given repository. User must have access to the repository.",
"operationId": "getYears",
"security": [
{
"CookieAuth": []
}
],
"parameters": [
{
"name": "repoID",
"in": "query",
"description": "Repository ID",
"required": true,
"schema": {
"type": "integer",
"format": "uint"
}
}
],
"responses": {
"200": {
"description": "List of years with tracked time data",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"type": "integer",
"format": "uint"
},
"example": [2023, 2024, 2025]
}
}
}
},
"400": {
"description": "Invalid repoID parameter or user does not have access to the repository",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Not authenticated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/api/v1/restricted/repo/get-quarters": {
"get": {
"tags": ["Repo"],
"summary": "Get quarterly time data for a repository",
"description": "Returns time tracked per quarter for the given repository and year. All 4 quarters are returned; quarters with no data have time=0. User must have access to the repository.",
"operationId": "getQuarters",
"security": [
{
"CookieAuth": []
}
],
"parameters": [
{
"name": "repoID",
"in": "query",
"description": "Repository ID",
"required": true,
"schema": {
"type": "integer",
"format": "uint"
}
},
{
"name": "year",
"in": "query",
"description": "Year to retrieve quarterly data for",
"required": true,
"schema": {
"type": "integer",
"format": "uint",
"example": 2024
}
}
],
"responses": {
"200": {
"description": "Quarterly time data",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/QuarterData"
}
}
}
}
},
"400": {
"description": "Invalid repoID or year parameter, or user does not have access to the repository",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Not authenticated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/api/v1/restricted/repo/get-issues": {
"get": {
"tags": ["Repo"],
"summary": "Get issues with time summaries",
"description": "Returns a paginated list of issues with time tracking summaries for the given repository, year, and quarter. User must have access to the repository.",
"operationId": "getIssues",
"security": [
{
"CookieAuth": []
}
],
"parameters": [
{
"name": "repoID",
"in": "query",
"description": "Repository ID",
"required": true,
"schema": {
"type": "integer",
"format": "uint"
}
},
{
"name": "year",
"in": "query",
"description": "Year to filter issues by",
"required": true,
"schema": {
"type": "integer",
"format": "uint",
"example": 2024
}
},
{
"name": "quarter",
"in": "query",
"description": "Quarter number (1-4) to filter issues by",
"required": true,
"schema": {
"type": "integer",
"format": "uint",
"minimum": 1,
"maximum": 4,
"example": 2
}
},
{
"name": "page_number",
"in": "query",
"description": "Page number for pagination (1-based)",
"required": true,
"schema": {
"type": "integer",
"format": "uint",
"example": 1
}
},
{
"name": "elements_per_page",
"in": "query",
"description": "Number of items per page",
"required": true,
"schema": {
"type": "integer",
"format": "uint",
"example": 30
}
}
],
"responses": {
"200": {
"description": "Paginated list of issues with time summaries",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PaginatedIssues"
}
}
}
},
"400": {
"description": "Invalid parameters or user does not have access to the repository",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Not authenticated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/api/v1/settings": {
"get": {
"tags": ["Settings"],
"summary": "Get application settings",
"description": "Returns public application settings and configuration",
"operationId": "getSettings",
"responses": {
"200": {
"description": "Settings retrieved successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SettingsResponse"
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"LoginRequest": {
"type": "object",
"required": ["email", "password"],
"properties": {
"email": {
"type": "string",
"format": "email",
"description": "User's email address"
},
"password": {
"type": "string",
"format": "password",
"description": "User's password"
}
}
},
"RegisterRequest": {
"type": "object",
"required": [
"email",
"password",
"confirm_password",
"first_name",
"last_name"
],
"properties": {
"email": {
"type": "string",
"format": "email",
"description": "User's email address"
},
"password": {
"type": "string",
"format": "password",
"description": "User's password (must meet complexity requirements: min 8 chars, uppercase, lowercase, digit)"
},
"confirm_password": {
"type": "string",
"format": "password",
"description": "Password confirmation"
},
"first_name": {
"type": "string",
"description": "User's first name (required)"
},
"last_name": {
"type": "string",
"description": "User's last name (required)"
},
"lang": {
"type": "string",
"description": "User's preferred language ISO code (e.g., 'en', 'pl', 'cs')"
}
}
},
"CompleteRegistrationRequest": {
"type": "object",
"required": ["token"],
"properties": {
"token": {
"type": "string",
"description": "Email verification token received via email"
}
}
},
"ResetPasswordRequest": {
"type": "object",
"required": ["token", "password"],
"properties": {
"token": {
"type": "string",
"description": "Password reset token received via email"
},
"password": {
"type": "string",
"format": "password",
"description": "New password (must meet complexity requirements)"
}
}
},
"AuthResponse": {
"type": "object",
"properties": {
"access_token": {
"type": "string",
"description": "JWT access token"
},
"token_type": {
"type": "string",
"example": "Bearer"
},
"expires_in": {
"type": "integer",
"description": "Access token expiration in seconds"
},
"user": {
"$ref": "#/components/schemas/UserSession"
}
}
},
"UserSession": {
"type": "object",
"properties": {
"user_id": {
"type": "integer",
"format": "uint",
"description": "User ID"
},
"email": {
"type": "string",
"format": "email"
},
"username": {
"type": "string"
},
"role": {
"type": "string",
"enum": ["user", "admin"],
"description": "User role"
},
"first_name": {
"type": "string"
},
"last_name": {
"type": "string"
},
"lang": {
"type": "string",
"description": "User's preferred language ISO code (e.g., 'en', 'pl', 'cs')"
}
}
},
"Error": {
"type": "object",
"properties": {
"error": {
"type": "string",
"description": "Translated error message"
}
}
},
"Language": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"format": "uint64",
"description": "Language ID"
},
"name": {
"type": "string",
"description": "Language name"
},
"iso_code": {
"type": "string",
"description": "ISO 639-1 code (e.g., 'en', 'pl')"
},
"lang_code": {
"type": "string",
"description": "Full language code (e.g., 'en-US', 'pl-PL')"
},
"date_format": {
"type": "string",
"description": "Date format string"
},
"date_format_short": {
"type": "string",
"description": "Short date format string"
},
"rtl": {
"type": "boolean",
"description": "Right-to-left language"
},
"is_default": {
"type": "boolean",
"description": "Is default language"
},
"active": {
"type": "boolean",
"description": "Is active"
},
"flag": {
"type": "string",
"description": "Flag emoji or code"
}
}
},
"QuarterData": {
"type": "object",
"description": "Time tracked in a specific quarter",
"properties": {
"time": {
"type": "number",
"format": "double",
"description": "Total hours tracked in this quarter"
},
"quarter": {
"type": "string",
"description": "Quarter identifier in format YYYY_QN (e.g., '2024_Q1')",
"example": "2024_Q1"
}
}
},
"IssueTimeSummary": {
"type": "object",
"description": "Time tracking summary for a single issue",
"properties": {
"issue_id": {
"type": "integer",
"format": "uint",
"description": "Issue ID"
},
"issue_name": {
"type": "string",
"description": "Issue title/name"
},
"user_id": {
"type": "integer",
"format": "uint",
"description": "ID of the user who tracked time"
},
"initials": {
"type": "string",
"description": "Abbreviated initials of the user (e.g., 'J.D.')"
},
"created_date": {
"type": "string",
"format": "date",
"description": "Date when time was tracked"
},
"total_hours_spent": {
"type": "number",
"format": "double",
"description": "Total hours spent on this issue on the given date (rounded to 2 decimal places)"
}
}
},
"PaginatedIssues": {
"type": "object",
"description": "Paginated list of issue time summaries",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/components/schemas/IssueTimeSummary"
},
"description": "List of issue time summaries for the current page"
},
"items_count": {
"type": "integer",
"format": "uint",
"description": "Total number of items across all pages",
"example": 56
}
}
},
"SettingsResponse": {
"type": "object",
"properties": {
"app": {
"$ref": "#/components/schemas/AppSettings"
},
"server": {
"$ref": "#/components/schemas/ServerSettings"
},
"auth": {
"$ref": "#/components/schemas/AuthSettings"
},
"features": {
"$ref": "#/components/schemas/FeatureFlags"
},
"version": {
"$ref": "#/components/schemas/VersionInfo"
}
}
},
"AppSettings": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Application name"
},
"environment": {
"type": "string",
"description": "Application environment (e.g., 'development', 'production')"
},
"base_url": {
"type": "string",
"description": "Base URL of the application"
},
"password_regex": {
"type": "string",
"description": "Regular expression for password validation"
}
}
},
"ServerSettings": {
"type": "object",
"properties": {
"port": {
"type": "integer",
"description": "Server port"
},
"host": {
"type": "string",
"description": "Server host"
}
}
},
"AuthSettings": {
"type": "object",
"properties": {
"jwt_expiration": {
"type": "integer",
"description": "JWT access token expiration in seconds"
},
"refresh_expiration": {
"type": "integer",
"description": "Refresh token expiration in seconds"
}
}
},
"FeatureFlags": {
"type": "object",
"properties": {
"email_enabled": {
"type": "boolean",
"description": "Whether email functionality is enabled"
},
"oauth_google": {
"type": "boolean",
"description": "Whether Google OAuth is enabled"
}
}
},
"VersionInfo": {
"type": "object",
"properties": {
"version": {
"type": "string",
"description": "Application version (git tag or commit hash)"
},
"commit": {
"type": "string",
"description": "Short git commit hash"
},
"build_date": {
"type": "string",
"format": "date-time",
"description": "Build date in RFC3339 format"
}
}
}
},
"securitySchemes": {
"CookieAuth": {
"type": "apiKey",
"in": "cookie",
"name": "access_token",
"description": "HTTPOnly JWT access token cookie set during login, registration, or token refresh"
},
"BearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT",
"description": "JWT token obtained from login response (alternative to cookie-based auth)"
}
}
}
}