diff --git a/backend/.env.example b/backend/.env.example deleted file mode 100644 index 949f2ee..0000000 --- a/backend/.env.example +++ /dev/null @@ -1,41 +0,0 @@ -NODE_ENV=development - -# Port config -PORT=3000 - -# Database -DATABASE_URL=mongodb://localhost/dbtest - -#JWT config -JWT_SECRET= -EXPPIRE_DAYS= - -#Hash password -SALT_ROUNDS= - -#CORS_ALLOW=http://localhost:3000 -CORS_ALLOW=* -HOST=http://localhost:3000 - -# Cloudinary -CLOUDINARY_NAME= -CLOUDINARY_API_KEY= -CLOUDINARY_API_SECRET= - -# knexjs -DB_TYPE=pg -DB_HOST=db -DB_PORT=5432 -DB_USER=postgres -DB_PASS=postgres -DB_NAME=boilerplate - - -# docker -# docker -POSTGRES_USER=postgres -POSTGRES_PASSWORD=postgres -POSTGRES_DB=boilerplate - -PGADMIN_DEFAULT_EMAIL=admin@gmail.com -PGADMIN_DEFAULT_PASSWORD=admin@gmail.com \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index 0fe11ed..845dbae 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -113,7 +113,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.16.12.tgz", "integrity": "sha512-dK5PtG1uiN2ikk++5OzSYsitZKny4wOCD0nrO4TqnW4BVBTQ2NGS3NgilvT/TEyxTST7LNyWV/T4tXDoD3fOgg==", "dev": true, - "peer": true, "dependencies": { "@babel/code-frame": "^7.16.7", "@babel/generator": "^7.16.8", @@ -2913,8 +2912,7 @@ "version": "20.5.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.5.1.tgz", "integrity": "sha512-4tT2UrL5LBqDwoed9wZ6N3umC4Yhz3W3FloMmiiG4JwmUJWpie0c7lcnUNd4gtMKuDEO4wRVS8B6Xa0uMRsMKg==", - "dev": true, - "peer": true + "dev": true }, "node_modules/@types/normalize-package-data": { "version": "2.4.3", @@ -2943,7 +2941,6 @@ "version": "7.4.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4998,7 +4995,6 @@ "version": "7.32.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.32.0.tgz", "integrity": "sha512-VHZ8gX+EDfz+97jGcgyGCyRia/dPOd6Xh9yPv8Bl1+SoaIwD+a/vlrOmGRUyOYu7MwUhc7CxqeaDZU13S4+EpA==", - "peer": true, "dependencies": { "@babel/code-frame": "7.12.11", "@eslint/eslintrc": "^0.4.3", @@ -5126,7 +5122,6 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.25.4.tgz", "integrity": "sha512-/KJBASVFxpu0xg1kIBn9AUa8hQVnszpwgE7Ld0lKAlx7Ie87yzEzCgSkekt+le/YVhiaosO4Y14GDAOc41nfxA==", "dev": true, - "peer": true, "dependencies": { "array-includes": "^3.1.4", "array.prototype.flat": "^1.2.5", @@ -7358,8 +7353,7 @@ "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "peer": true + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "node_modules/lodash.camelcase": { "version": "4.3.0", @@ -8506,7 +8500,6 @@ "version": "8.7.1", "resolved": "https://registry.npmjs.org/pg/-/pg-8.7.1.tgz", "integrity": "sha512-7bdYcv7V6U3KAtWjpQJJBww0UEsWuh4yQ/EjNf2HeO/NnvKjpvhEIe/A/TleP6wtmSKnUnghs5A9jUoK6iDdkA==", - "peer": true, "dependencies": { "buffer-writer": "2.0.0", "packet-reader": "1.0.0", @@ -8827,7 +8820,6 @@ "version": "2.5.1", "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.5.1.tgz", "integrity": "sha512-vBZcPRUR5MZJwoyi3ZoyQlc1rXeEck8KgeC9AwwOn+exuxLxq5toTRDTSaVrXHxelDMHy9zlicw8u66yxoSUFg==", - "peer": true, "bin": { "prettier": "bin-prettier.js" }, @@ -10212,7 +10204,6 @@ "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", "dev": true, - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -10346,7 +10337,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", "dev": true, - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11060,7 +11050,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.16.12.tgz", "integrity": "sha512-dK5PtG1uiN2ikk++5OzSYsitZKny4wOCD0nrO4TqnW4BVBTQ2NGS3NgilvT/TEyxTST7LNyWV/T4tXDoD3fOgg==", "dev": true, - "peer": true, "requires": { "@babel/code-frame": "^7.16.7", "@babel/generator": "^7.16.8", @@ -13078,8 +13067,7 @@ "version": "20.5.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.5.1.tgz", "integrity": "sha512-4tT2UrL5LBqDwoed9wZ6N3umC4Yhz3W3FloMmiiG4JwmUJWpie0c7lcnUNd4gtMKuDEO4wRVS8B6Xa0uMRsMKg==", - "dev": true, - "peer": true + "dev": true }, "@types/normalize-package-data": { "version": "2.4.3", @@ -13104,8 +13092,7 @@ "acorn": { "version": "7.4.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", - "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", - "peer": true + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==" }, "acorn-jsx": { "version": "5.3.2", @@ -14637,7 +14624,6 @@ "version": "7.32.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.32.0.tgz", "integrity": "sha512-VHZ8gX+EDfz+97jGcgyGCyRia/dPOd6Xh9yPv8Bl1+SoaIwD+a/vlrOmGRUyOYu7MwUhc7CxqeaDZU13S4+EpA==", - "peer": true, "requires": { "@babel/code-frame": "7.12.11", "@eslint/eslintrc": "^0.4.3", @@ -14837,7 +14823,6 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.25.4.tgz", "integrity": "sha512-/KJBASVFxpu0xg1kIBn9AUa8hQVnszpwgE7Ld0lKAlx7Ie87yzEzCgSkekt+le/YVhiaosO4Y14GDAOc41nfxA==", "dev": true, - "peer": true, "requires": { "array-includes": "^3.1.4", "array.prototype.flat": "^1.2.5", @@ -16337,8 +16322,7 @@ "lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "peer": true + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "lodash.camelcase": { "version": "4.3.0", @@ -17208,7 +17192,6 @@ "version": "8.7.1", "resolved": "https://registry.npmjs.org/pg/-/pg-8.7.1.tgz", "integrity": "sha512-7bdYcv7V6U3KAtWjpQJJBww0UEsWuh4yQ/EjNf2HeO/NnvKjpvhEIe/A/TleP6wtmSKnUnghs5A9jUoK6iDdkA==", - "peer": true, "requires": { "buffer-writer": "2.0.0", "packet-reader": "1.0.0", @@ -17435,8 +17418,7 @@ "prettier": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.5.1.tgz", - "integrity": "sha512-vBZcPRUR5MZJwoyi3ZoyQlc1rXeEck8KgeC9AwwOn+exuxLxq5toTRDTSaVrXHxelDMHy9zlicw8u66yxoSUFg==", - "peer": true + "integrity": "sha512-vBZcPRUR5MZJwoyi3ZoyQlc1rXeEck8KgeC9AwwOn+exuxLxq5toTRDTSaVrXHxelDMHy9zlicw8u66yxoSUFg==" }, "prettier-linter-helpers": { "version": "1.0.0", @@ -18501,7 +18483,6 @@ "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", "dev": true, - "peer": true, "requires": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -18595,8 +18576,7 @@ "version": "5.2.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", - "dev": true, - "peer": true + "dev": true }, "unbox-primitive": { "version": "1.0.1", diff --git a/backend/src/core/database/migrations/20220126075331_users.js b/backend/src/core/database/migrations/20220126075331_users.js deleted file mode 100644 index 7bbe484..0000000 --- a/backend/src/core/database/migrations/20220126075331_users.js +++ /dev/null @@ -1,27 +0,0 @@ -// @ts-check -/** - * @param {import("knex")} knex - */ -const tableName = 'users'; -// 123456 -const DEFAULT_PASSWORD = '$2b$10$4WxWKojNnKfDAicVsveh7.ogkWOBMV1cvRUSPCXwxA05NRX18F0QW'; -exports.up = async knex => { - await knex.schema.createTable(tableName, table => { - table.increments('id').unsigned().primary(); - table.string('full_name'); - table.string('email').unique().notNullable(); - table.string('password').defaultTo(DEFAULT_PASSWORD); - table.dateTime('deleted_at').defaultTo(null); - table.timestamps(false, true); - }); - - await knex.raw(` - CREATE TRIGGER update_timestamp - BEFORE UPDATE - ON ${tableName} - FOR EACH ROW - EXECUTE PROCEDURE update_timestamp(); - `); -}; - -exports.down = knex => knex.schema.dropTable(tableName); diff --git a/backend/src/core/database/migrations/20220126075339_roles.js b/backend/src/core/database/migrations/20220126075339_roles.js deleted file mode 100644 index 0dc0df5..0000000 --- a/backend/src/core/database/migrations/20220126075339_roles.js +++ /dev/null @@ -1,23 +0,0 @@ -// @ts-check -/** - * @param {import("knex")} knex - */ -const tableName = 'roles'; -exports.up = async knex => { - await knex.schema.createTable(tableName, table => { - table.increments('id').unsigned().primary(); - table.string('name'); - table.dateTime('deleted_at').defaultTo(null); - table.timestamps(false, true); - }); - - await knex.raw(` - CREATE TRIGGER update_timestamp - BEFORE UPDATE - ON ${tableName} - FOR EACH ROW - EXECUTE PROCEDURE update_timestamp(); - `); -}; - -exports.down = knex => knex.schema.dropTable(tableName); diff --git a/backend/src/core/database/migrations/20220126075512_users_roles.js b/backend/src/core/database/migrations/20220126075512_users_roles.js deleted file mode 100644 index d440745..0000000 --- a/backend/src/core/database/migrations/20220126075512_users_roles.js +++ /dev/null @@ -1,29 +0,0 @@ -// @ts-check -/** - * @param {import("knex")} knex - */ -const tableName = 'users_roles'; -exports.up = async knex => { - await knex.schema.createTable(tableName, table => { - table.integer('user_id').unsigned().references('id').inTable('users') - .onDelete('CASCADE') - .notNullable(); - table.integer('role_id').unsigned().references('id').inTable('roles') - .notNullable(); - - table.dateTime('deleted_at').defaultTo(null); - table.timestamps(false, true); - - table.primary(['user_id', 'role_id']); - }); - - await knex.raw(` - CREATE TRIGGER update_timestamp - BEFORE UPDATE - ON ${tableName} - FOR EACH ROW - EXECUTE PROCEDURE update_timestamp(); - `); -}; - -exports.down = knex => knex.schema.dropTable(tableName); diff --git a/backend/src/core/database/migrations/20260426162249_init_extension_enums.js b/backend/src/core/database/migrations/20260426162249_init_extension_enums.js new file mode 100644 index 0000000..ff77491 --- /dev/null +++ b/backend/src/core/database/migrations/20260426162249_init_extension_enums.js @@ -0,0 +1,42 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = async (knex) => { + await knex.raw(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`); + + await knex.raw(` + DO $$ BEGIN + CREATE TYPE lawyer_status AS ENUM ('AVAILABLE', 'BUSY', 'OFFLINE'); + EXCEPTION WHEN duplicate_object THEN null; END $$; + `); + + await knex.raw(` + DO $$ BEGIN + CREATE TYPE report_status AS ENUM ('PENDING', 'RESOLVED'); + EXCEPTION WHEN duplicate_object THEN null; END $$; + `); + + await knex.raw(` + DO $$ BEGIN + CREATE TYPE ai_role AS ENUM ('USER', 'AI'); + EXCEPTION WHEN duplicate_object THEN null; END $$; + `); + + await knex.raw(` + DO $$ BEGIN + CREATE TYPE chat_request_status AS ENUM ('PENDING', 'ACCEPTED', 'REJECTED'); + EXCEPTION WHEN duplicate_object THEN null; END $$; + `); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = async (knex) => { + await knex.raw(`DROP TYPE IF EXISTS chat_request_status`); + await knex.raw(`DROP TYPE IF EXISTS ai_role`); + await knex.raw(`DROP TYPE IF EXISTS report_status`); + await knex.raw(`DROP TYPE IF EXISTS lawyer_status`); +}; diff --git a/backend/src/core/database/migrations/20260426162901_create_roles.js b/backend/src/core/database/migrations/20260426162901_create_roles.js new file mode 100644 index 0000000..277b04e --- /dev/null +++ b/backend/src/core/database/migrations/20260426162901_create_roles.js @@ -0,0 +1,20 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = async (knex) => { + await knex.schema.createTable('roles', table => { + table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()')); + table.string('name', 50).unique().notNullable(); + + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + table.timestamp('deleted_at'); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = knex => knex.schema.dropTable('roles'); diff --git a/backend/src/core/database/migrations/20260426163046_create_users.js b/backend/src/core/database/migrations/20260426163046_create_users.js new file mode 100644 index 0000000..6f4c21b --- /dev/null +++ b/backend/src/core/database/migrations/20260426163046_create_users.js @@ -0,0 +1,56 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = async (knex) => { + await knex.schema.createTable('users', table => { + table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()')); + + table.string('email').notNullable(); + table.text('password_hash').notNullable(); + table.string('full_name').notNullable(); + + table.uuid('role_id').references('id').inTable('roles'); + + table.text('avatar_url'); + + table.string('referral_code', 50); + table.uuid('referred_by').references('id').inTable('users'); + + table.string('phone', 20); + table.date('date_of_birth'); + table.string('location'); + + table.text('email_confirmation_token'); + + table.text('password_reset_token'); + table.timestamp('password_reset_expiry'); + + table.boolean('is_email_confirmed').notNullable().defaultTo(false); + + table.timestamp('last_login_at'); + + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + table.timestamp('deleted_at'); + }); + + // partial index + await knex.raw(` + CREATE UNIQUE INDEX unique_users_email_active + ON users(email) + WHERE deleted_at IS NULL; + `); + + await knex.raw(` + CREATE UNIQUE INDEX unique_users_referral_active + ON users(referral_code) + WHERE deleted_at IS NULL; + `); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = knex => knex.schema.dropTable('users'); diff --git a/backend/src/core/database/migrations/20260426163316_create_lawyer_details.js b/backend/src/core/database/migrations/20260426163316_create_lawyer_details.js new file mode 100644 index 0000000..1329fff --- /dev/null +++ b/backend/src/core/database/migrations/20260426163316_create_lawyer_details.js @@ -0,0 +1,35 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = async (knex) => { + await knex.schema.createTable('lawyer_details', table => { + table.uuid('user_id').primary() + .references('id') + .inTable('users') + .onDelete('CASCADE'); + + table.text('bio'); + table.integer('experience_years'); + + table.boolean('is_verified').notNullable().defaultTo(false); + table.float('rating_avg').defaultTo(0); + + table.decimal('price_per_hour', 10, 2); + + table.specificType('status', 'lawyer_status').notNullable().defaultTo('OFFLINE'); + + table.string('bar_association'); + table.string('license_number'); + + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + table.timestamp('deleted_at'); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = knex => knex.schema.dropTable('lawyer_details'); diff --git a/backend/src/core/database/migrations/20260426181537_create_specialties.js b/backend/src/core/database/migrations/20260426181537_create_specialties.js new file mode 100644 index 0000000..d41388a --- /dev/null +++ b/backend/src/core/database/migrations/20260426181537_create_specialties.js @@ -0,0 +1,20 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = async (knex) => { + await knex.schema.createTable('specialties', table => { + table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()')); + table.string('name').unique().notNullable(); + + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + table.timestamp('deleted_at'); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = knex => knex.schema.dropTable('specialties'); diff --git a/backend/src/core/database/migrations/20260426181638_create_lawyer_specialties.js b/backend/src/core/database/migrations/20260426181638_create_lawyer_specialties.js new file mode 100644 index 0000000..3e6f198 --- /dev/null +++ b/backend/src/core/database/migrations/20260426181638_create_lawyer_specialties.js @@ -0,0 +1,23 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = async (knex) => { + await knex.schema.createTable('lawyer_specialties', table => { + table.uuid('lawyer_id') + .references('user_id').inTable('lawyer_details') + .onDelete('CASCADE'); + + table.uuid('specialty_id') + .references('id').inTable('specialties') + .onDelete('CASCADE'); + + table.primary(['lawyer_id', 'specialty_id']); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = knex => knex.schema.dropTable('lawyer_specialties'); diff --git a/backend/src/core/database/migrations/20260426181647_create_lawyer_certificates.js b/backend/src/core/database/migrations/20260426181647_create_lawyer_certificates.js new file mode 100644 index 0000000..82fe275 --- /dev/null +++ b/backend/src/core/database/migrations/20260426181647_create_lawyer_certificates.js @@ -0,0 +1,25 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = async (knex) => { + await knex.schema.createTable('lawyer_certificates', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()')); + table.uuid('lawyer_id').references('user_id').inTable('lawyer_details').onDelete('CASCADE'); + + table.string('certificate_name').notNullable(); + table.text('file_url').notNullable(); + table.string('issued_by').notNullable(); + table.date('issue_date').notNullable(); + + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + table.timestamp('deleted_at'); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = knex => knex.schema.dropTable('lawyer_certificates'); diff --git a/backend/src/core/database/migrations/20260426181700_create_lawyer_experiences.js b/backend/src/core/database/migrations/20260426181700_create_lawyer_experiences.js new file mode 100644 index 0000000..fcbf7bb --- /dev/null +++ b/backend/src/core/database/migrations/20260426181700_create_lawyer_experiences.js @@ -0,0 +1,27 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = async (knex) => { + await knex.schema.createTable('lawyer_experiences', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()')); + table.uuid('lawyer_id').references('user_id').inTable('lawyer_details').onDelete('CASCADE'); + + table.string('title').notNullable(); + table.text('description').notNullable(); + table.date('start_date').notNullable(); + table.date('end_date'); + + table.check('end_date IS NULL OR end_date >= start_date'); + + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + table.timestamp('deleted_at'); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = knex => knex.schema.dropTable('lawyer_experiences'); diff --git a/backend/src/core/database/migrations/20260426181929_create_ratings.js b/backend/src/core/database/migrations/20260426181929_create_ratings.js new file mode 100644 index 0000000..feddbec --- /dev/null +++ b/backend/src/core/database/migrations/20260426181929_create_ratings.js @@ -0,0 +1,27 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = async (knex) => { + await knex.schema.createTable('ratings', (table) => { + table.uuid('user_id').references('id').inTable('users').onDelete('CASCADE'); + table.uuid('lawyer_id').references('user_id').inTable('lawyer_details').onDelete('CASCADE'); + + table.integer('rating').notNullable().checkBetween([1, 5]); + table.text('comment').notNullable(); + + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + table.timestamp('deleted_at'); + + table.primary(['user_id', 'lawyer_id']); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = async (knex) => { + await knex.schema.dropTable('ratings'); +}; diff --git a/backend/src/core/database/migrations/20260426181937_create_reports.js b/backend/src/core/database/migrations/20260426181937_create_reports.js new file mode 100644 index 0000000..cf5e270 --- /dev/null +++ b/backend/src/core/database/migrations/20260426181937_create_reports.js @@ -0,0 +1,27 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = async (knex) => { + await knex.schema.createTable('reports', (table) => { + table.uuid('target_user_id').references('id').inTable('users').onDelete('CASCADE'); + table.uuid('reporter_id').references('user_id').inTable('lawyer_details').onDelete('CASCADE'); + + table.text('reason').notNullable(); + table.specificType('status', 'report_status').defaultTo('PENDING'); + + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + table.timestamp('deleted_at'); + + table.primary(['target_user_id', 'reporter_id']); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = async (knex) => { + await knex.schema.dropTable('reports'); +}; diff --git a/backend/src/core/database/migrations/20260426184537_create_subscriptions.js b/backend/src/core/database/migrations/20260426184537_create_subscriptions.js new file mode 100644 index 0000000..b3b1088 --- /dev/null +++ b/backend/src/core/database/migrations/20260426184537_create_subscriptions.js @@ -0,0 +1,35 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = async (knex) => { + await knex.schema.createTable('subscriptions', table => { + table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()')); + + table.uuid('user_id').references('id').inTable('users').onDelete('CASCADE'); + + table.string('plan_name'); + table.timestamp('start_date'); + table.timestamp('end_date'); + + table.integer('quota'); + table.boolean('is_active'); + + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + table.timestamp('deleted_at'); + }); + + await knex.raw(` + ALTER TABLE subscriptions + ADD CONSTRAINT check_quota_non_negative CHECK (quota >= 0); + `); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = async (knex) => { + await knex.schema.dropTable('subscriptions'); +}; diff --git a/backend/src/core/database/migrations/20260426184850_subscription_trigger.js b/backend/src/core/database/migrations/20260426184850_subscription_trigger.js new file mode 100644 index 0000000..4740ac3 --- /dev/null +++ b/backend/src/core/database/migrations/20260426184850_subscription_trigger.js @@ -0,0 +1,22 @@ +exports.up = knex => knex.raw(` +CREATE OR REPLACE FUNCTION update_subscription_status() +RETURNS TRIGGER AS $$ +BEGIN + NEW.is_active := + (NEW.quota > 0) + AND (NEW.end_date > NOW()) + AND (NEW.deleted_at IS NULL); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_update_subscription_status +BEFORE INSERT OR UPDATE ON subscriptions +FOR EACH ROW +EXECUTE FUNCTION update_subscription_status(); +`); + +exports.down = knex => knex.raw(` +DROP TRIGGER IF EXISTS trg_update_subscription_status ON subscriptions; +DROP FUNCTION IF EXISTS update_subscription_status() CASCADE; +`); diff --git a/backend/src/core/database/migrations/20260426185236_create_laws.js b/backend/src/core/database/migrations/20260426185236_create_laws.js new file mode 100644 index 0000000..80f4b83 --- /dev/null +++ b/backend/src/core/database/migrations/20260426185236_create_laws.js @@ -0,0 +1,32 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = async (knex) => { + await knex.schema.createTable('laws', table => { + table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()')); + + table.text('title').notNullable(); + table.text('content').notNullable(); + + table.string('document_number').notNullable(); + table.date('issued_date').notNullable(); + table.date('effective_date').notNullable(); + table.text('source_url').notNullable(); + + table.uuid('user_id') + .notNullable() + .references('id') + .inTable('users'); + + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + table.timestamp('deleted_at'); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = knex => knex.schema.dropTable('laws'); diff --git a/backend/src/core/database/migrations/20260426185357_create_templates_html.js b/backend/src/core/database/migrations/20260426185357_create_templates_html.js new file mode 100644 index 0000000..4b46e3d --- /dev/null +++ b/backend/src/core/database/migrations/20260426185357_create_templates_html.js @@ -0,0 +1,23 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = async (knex) => { + await knex.schema.createTable('templates', table => { + table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()')); + + table.string('name').notNullable(); + table.text('description'); + table.text('file_url').notNullable(); + + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + table.timestamp('deleted_at'); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = knex => knex.schema.dropTable('templates'); diff --git a/backend/src/core/database/migrations/20260426190629_create_documents.js b/backend/src/core/database/migrations/20260426190629_create_documents.js new file mode 100644 index 0000000..c082a05 --- /dev/null +++ b/backend/src/core/database/migrations/20260426190629_create_documents.js @@ -0,0 +1,33 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = async (knex) => { + await knex.schema.createTable('documents', table => { + table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()')); + + table.uuid('user_id') + .references('id') + .inTable('users') + .onDelete('CASCADE'); + + table.uuid('template_id') + .references('id') + .inTable('templates'); + + table.jsonb('content'); + + table.text('file_url').nullable(); + table.boolean('is_draft').defaultTo(true); + + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + table.timestamp('deleted_at'); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = knex => knex.schema.dropTable('documents'); diff --git a/backend/src/core/database/migrations/20260426190943_create_analysis.js b/backend/src/core/database/migrations/20260426190943_create_analysis.js new file mode 100644 index 0000000..4b79dcd --- /dev/null +++ b/backend/src/core/database/migrations/20260426190943_create_analysis.js @@ -0,0 +1,28 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = async (knex) => { + await knex.schema.createTable('analysis', table => { + table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()')); + + table.uuid('user_id') + .references('id') + .inTable('users') + .onDelete('CASCADE'); + + table.jsonb('input_data'); + table.text('result'); + table.text('context_summary'); + + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + table.timestamp('deleted_at'); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = knex => knex.schema.dropTable('analysis'); diff --git a/backend/src/core/database/migrations/20260426191243_create_ai_messages.js b/backend/src/core/database/migrations/20260426191243_create_ai_messages.js new file mode 100644 index 0000000..898546f --- /dev/null +++ b/backend/src/core/database/migrations/20260426191243_create_ai_messages.js @@ -0,0 +1,27 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = async (knex) => { + await knex.schema.createTable('ai_messages', table => { + table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()')); + + table.uuid('analysis_id') + .references('id') + .inTable('analysis') + .onDelete('CASCADE'); + + table.specificType('role', 'ai_role'); + table.text('content'); + + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + table.timestamp('deleted_at'); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = knex => knex.schema.dropTable('ai_messages'); diff --git a/backend/src/core/database/migrations/20260426191513_create_conversations.js b/backend/src/core/database/migrations/20260426191513_create_conversations.js new file mode 100644 index 0000000..cedab72 --- /dev/null +++ b/backend/src/core/database/migrations/20260426191513_create_conversations.js @@ -0,0 +1,29 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = async (knex) => { + await knex.schema.createTable('conversations', table => { + table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()')); + + table.uuid('user_id') + .references('id') + .inTable('users'); + + table.uuid('lawyer_id') + .references('user_id') + .inTable('lawyer_details'); + + table.text('context_summary').nullable(); + + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + table.timestamp('deleted_at'); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = knex => knex.schema.dropTable('conversations'); diff --git a/backend/src/core/database/migrations/20260426192416_create_messages.js b/backend/src/core/database/migrations/20260426192416_create_messages.js new file mode 100644 index 0000000..ccc85ad --- /dev/null +++ b/backend/src/core/database/migrations/20260426192416_create_messages.js @@ -0,0 +1,30 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = async (knex) => { + await knex.schema.createTable('messages', table => { + table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()')); + + table.uuid('conversation_id') + .references('id') + .inTable('conversations') + .onDelete('CASCADE'); + + table.uuid('sender_id') + .references('id') + .inTable('users'); + + table.text('content'); + + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + table.timestamp('deleted_at'); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = knex => knex.schema.dropTable('messages'); diff --git a/backend/src/core/database/migrations/20260426192617_create_refresh_tokens.js b/backend/src/core/database/migrations/20260426192617_create_refresh_tokens.js new file mode 100644 index 0000000..db50fdf --- /dev/null +++ b/backend/src/core/database/migrations/20260426192617_create_refresh_tokens.js @@ -0,0 +1,55 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = async (knex) => { + await knex.schema.createTable('refresh_tokens', table => { + // PK + table.uuid('id') + .primary() + .defaultTo(knex.raw('uuid_generate_v4()')); + + // FK user + table.uuid('user_id') + .notNullable() + .unique() // mỗi user 1 token (theo thiết kế của bạn) + .references('id') + .inTable('users') + .onDelete('CASCADE'); + + // token (nên lưu hash) + table.text('token').notNullable(); + + // expire + table.timestamp('expires_at').notNullable(); + + // revoke + table.boolean('is_revoked').defaultTo(false); + + // timestamps + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + table.timestamp('deleted_at'); + }); + + // unique token nhưng chỉ khi chưa bị delete + await knex.raw(` + CREATE UNIQUE INDEX unique_refresh_token_active + ON refresh_tokens(token) + WHERE deleted_at IS NULL; + `); + + // index để query nhanh theo user + await knex.raw(` + CREATE INDEX idx_refresh_tokens_user + ON refresh_tokens(user_id); + `); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = async (knex) => { + await knex.schema.dropTable('refresh_tokens'); +}; diff --git a/backend/src/core/database/migrations/20260426194833_create_chat_requests.js b/backend/src/core/database/migrations/20260426194833_create_chat_requests.js new file mode 100644 index 0000000..028322e --- /dev/null +++ b/backend/src/core/database/migrations/20260426194833_create_chat_requests.js @@ -0,0 +1,30 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = async (knex) => { + await knex.schema.createTable('chat_requests', table => { + table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()')); + + table.uuid('user_id') + .references('id') + .inTable('users'); + + table.uuid('lawyer_id') + .references('user_id') + .inTable('lawyer_details'); + + table.specificType('status', 'chat_request_status') + .defaultTo('PENDING'); + + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + table.timestamp('deleted_at'); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = knex => knex.schema.dropTable('chat_requests'); diff --git a/backend/src/core/database/seeds/01-users.js b/backend/src/core/database/seeds/01-users.js deleted file mode 100644 index 0f73fce..0000000 --- a/backend/src/core/database/seeds/01-users.js +++ /dev/null @@ -1,20 +0,0 @@ -/** - * @param {import("knex")} knex - */ - -exports.seed = knex => knex('users') - .del() - .then(() => knex('users').insert([ - { - full_name: 'Super Admin', - email: 'spadmin@gmail.com', - }, - { - full_name: 'Admin', - email: 'admin@gmail.com', - }, - { - full_name: 'User', - email: 'user@gmail.com', - }, - ])); diff --git a/backend/src/core/database/seeds/01_roles_seed.js b/backend/src/core/database/seeds/01_roles_seed.js new file mode 100644 index 0000000..b0e1b82 --- /dev/null +++ b/backend/src/core/database/seeds/01_roles_seed.js @@ -0,0 +1,13 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.seed = async (knex) => { + await knex('roles').del(); + + await knex('roles').insert([ + { id: knex.raw('uuid_generate_v4()'), name: 'USER' }, + { id: knex.raw('uuid_generate_v4()'), name: 'LAWYER' }, + { id: knex.raw('uuid_generate_v4()'), name: 'ADMIN' }, + ]); +}; diff --git a/backend/src/core/database/seeds/02-roles.js b/backend/src/core/database/seeds/02-roles.js deleted file mode 100644 index b869303..0000000 --- a/backend/src/core/database/seeds/02-roles.js +++ /dev/null @@ -1,17 +0,0 @@ -/** - * @param {import("knex")} knex - */ - -exports.seed = knex => knex('roles') - .del() - .then(() => knex('roles').insert([ - { - name: 'SUPER_ADMIN', - }, - { - name: 'ADMIN', - }, - { - name: 'USER', - }, - ])); diff --git a/backend/src/core/database/seeds/02_users_seed.js b/backend/src/core/database/seeds/02_users_seed.js new file mode 100644 index 0000000..2582774 --- /dev/null +++ b/backend/src/core/database/seeds/02_users_seed.js @@ -0,0 +1,42 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ + +const bcrypt = require('bcrypt'); + +exports.seed = async (knex) => { + await knex('users').del(); + + const roles = await knex('roles').select('*'); + + const userRole = roles.find(r => r.name === 'USER'); + const lawyerRole = roles.find(r => r.name === 'LAWYER'); + const adminRole = roles.find(r => r.name === 'ADMIN'); + + const password = bcrypt.hashSync('123456', 10); + + await knex('users').insert([ + { + id: knex.raw('uuid_generate_v4()'), + email: 'user@gmail.com', + password_hash: password, + full_name: 'Normal User', + role_id: userRole.id, + }, + { + id: knex.raw('uuid_generate_v4()'), + email: 'lawyer@gmail.com', + password_hash: password, + full_name: 'Lawyer User', + role_id: lawyerRole.id, + }, + { + id: knex.raw('uuid_generate_v4()'), + email: 'admin@gmail.com', + password_hash: password, + full_name: 'Admin User', + role_id: adminRole.id, + }, + ]); +}; \ No newline at end of file diff --git a/backend/src/core/database/seeds/03-users_roles.js b/backend/src/core/database/seeds/03-users_roles.js deleted file mode 100644 index 2eb7164..0000000 --- a/backend/src/core/database/seeds/03-users_roles.js +++ /dev/null @@ -1,32 +0,0 @@ -/** - * @param {import("knex")} knex - */ - -exports.seed = knex => knex('users_roles') - .del() - .then(() => knex('users_roles').insert([ - { - user_id: 1, - role_id: 1, - }, - { - user_id: 1, - role_id: 2, - }, - { - user_id: 1, - role_id: 3, - }, - { - user_id: 2, - role_id: 2, - }, - { - user_id: 2, - role_id: 3, - }, - { - user_id: 3, - role_id: 3, - }, - ])); diff --git a/backend/src/core/database/seeds/03_lawyer_details_seed.js b/backend/src/core/database/seeds/03_lawyer_details_seed.js new file mode 100644 index 0000000..f9d5cb4 --- /dev/null +++ b/backend/src/core/database/seeds/03_lawyer_details_seed.js @@ -0,0 +1,24 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ + +exports.seed = async (knex) => { + await knex('lawyer_details').del(); + + const lawyerUser = await knex('users') + .where('email', 'lawyer@gmail.com') + .first(); + + await knex('lawyer_details').insert({ + user_id: lawyerUser.id, + bio: 'Luật sư chuyên về dân sự và doanh nghiệp', + experience_years: 5, + is_verified: true, + rating_avg: 4.5, + price_per_hour: 50, + status: 'AVAILABLE', + bar_association: 'Vietnam Bar Federation', + license_number: 'LAW123456', + }); +}; diff --git a/backend/src/core/database/seeds/04_specialties_seed.js b/backend/src/core/database/seeds/04_specialties_seed.js new file mode 100644 index 0000000..8b0b3bd --- /dev/null +++ b/backend/src/core/database/seeds/04_specialties_seed.js @@ -0,0 +1,15 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ + +exports.seed = async (knex) => { + await knex('specialties').del(); + + await knex('specialties').insert([ + { id: knex.raw('uuid_generate_v4()'), name: 'Hình sự' }, + { id: knex.raw('uuid_generate_v4()'), name: 'Dân sự' }, + { id: knex.raw('uuid_generate_v4()'), name: 'Doanh nghiệp' }, + { id: knex.raw('uuid_generate_v4()'), name: 'Đất đai' }, + ]); +};