diff --git a/.gitignore b/.gitignore index 4c2d9f0..81c5303 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ /frontend-angular/.angular/ /backend-node/node_modules/ /backend-node/.env +/frontend-angular/node_modules/ +/.claude/ diff --git a/backend-node/Dockerfile b/backend-node/Dockerfile index 136db39..15b4824 100644 --- a/backend-node/Dockerfile +++ b/backend-node/Dockerfile @@ -22,7 +22,7 @@ COPY package*.json ./ FROM base AS dependencies # Install ALL dependencies (including devDependencies for build) -RUN npm ci +RUN npm install # ======================================== # Stage 3: Development @@ -50,7 +50,7 @@ CMD ["npm", "start"] FROM base AS production-dependencies # Install only production dependencies -RUN npm ci --only=production && \ +RUN npm install --only=production && \ npm cache clean --force # ======================================== diff --git a/backend-node/controllers/bets.controller.js b/backend-node/controllers/bets.controller.js index 03b9114..8a0975f 100644 --- a/backend-node/controllers/bets.controller.js +++ b/backend-node/controllers/bets.controller.js @@ -5,6 +5,24 @@ class BetsController { this.betsService = betsService; } + async getUserBets(req, res, next) { + try { + const bets = await this.betsService.getUserBets(req.userId); + return ResponseHandler.success(res, bets, 'User bets retrieved successfully'); + } catch (error) { + next(error); + } + } + + async getPublicBets(req, res, next) { + try { + const bets = await this.betsService.getPublicBetsExcludingUser(req.userId); + return ResponseHandler.success(res, bets, 'Public bets retrieved successfully'); + } catch (error) { + next(error); + } + } + async getAllBets(req, res, next) { try { const bets = await this.betsService.getAllBets(); diff --git a/backend-node/controllers/groups.controller.js b/backend-node/controllers/groups.controller.js index c96f5f2..0f8cadd 100644 --- a/backend-node/controllers/groups.controller.js +++ b/backend-node/controllers/groups.controller.js @@ -7,7 +7,7 @@ class GroupsController { async getAllGroups(req, res, next) { try { - const groups = await this.groupsService.getAllGroups(); + const groups = await this.groupsService.getAllGroups(req.userId); return ResponseHandler.success(res, groups, 'Groups retrieved successfully'); } catch (error) { next(error); @@ -52,10 +52,7 @@ class GroupsController { async addMembers(req, res, next) { try { - const group = await this.groupsService.addMembersToGroup( - req.params.id, - req.body.userIds - ); + const group = await this.groupsService.addMembersToGroup(req.params.id, req.body.userIds); return ResponseHandler.success(res, group, 'Members added successfully'); } catch (error) { next(error); diff --git a/backend-node/controllers/initialData.controller.js b/backend-node/controllers/initialData.controller.js deleted file mode 100644 index 3138e9b..0000000 --- a/backend-node/controllers/initialData.controller.js +++ /dev/null @@ -1,38 +0,0 @@ -const ResponseHandler = require('../shared/response.handler'); - -class InitialDataController { - constructor({ usersService, friendsService, groupsService, betsService }) { - this.usersService = usersService; - this.friendsService = friendsService; - this.groupsService = groupsService; - this.betsService = betsService; - } - - async getInitialData(req, res, next) { - try { - const currentUser = await this.usersService.getUserById(req.userId); - const users = await this.usersService.getAllUsers(); - const friends = await this.friendsService.getFriendsByUserId(req.userId); - const friendRequests = await this.friendsService.getPendingRequests(req.userId); - const groups = await this.groupsService.getAllGroups(); - const userBets = await this.betsService.getUserBets(req.userId); - const publicBets = await this.betsService.getPublicBetsExcludingUser(req.userId); - - const data = { - currentUser, - users, - friends, - friendRequests, - groups, - userBets, - publicBets, - }; - - return ResponseHandler.success(res, data, 'Initial data retrieved successfully'); - } catch (error) { - next(error); - } - } -} - -module.exports = InitialDataController; diff --git a/backend-node/controllers/users.controller.js b/backend-node/controllers/users.controller.js index 1a0e72e..92fc887 100644 --- a/backend-node/controllers/users.controller.js +++ b/backend-node/controllers/users.controller.js @@ -5,6 +5,15 @@ class UsersController { this.usersService = usersService; } + async getCurrentUser(req, res, next) { + try { + const user = await this.usersService.getUserById(req.userId); + return ResponseHandler.success(res, user, 'User retrieved successfully'); + } catch (error) { + next(error); + } + } + async updateCurrentUser(req, res, next) { try { const user = await this.usersService.updateUserProfile(req.userId, req.body); diff --git a/backend-node/di/container.js b/backend-node/di/container.js index cdb78cb..2bbf61f 100644 --- a/backend-node/di/container.js +++ b/backend-node/di/container.js @@ -18,13 +18,13 @@ function setupContainer() { const modelsDir = path.join(__dirname, '../models'); fs.readdirSync(modelsDir) .filter( - (file) => + file => file.indexOf('.') !== 0 && file.slice(-3) === '.js' && file !== 'bet.model.js' && file !== 'associations.js' ) - .forEach((file) => { + .forEach(file => { require(path.join(modelsDir, file))(sequelize); }); @@ -38,27 +38,19 @@ function setupContainer() { }); container.register({ - usersRepository: asClass( - require('../repositories/postgres/users.repository') - ).scoped(), - groupsRepository: asClass( - require('../repositories/postgres/groups.repository') - ).scoped(), + usersRepository: asClass(require('../repositories/postgres/users.repository')).scoped(), + groupsRepository: asClass(require('../repositories/postgres/groups.repository')).scoped(), friendshipsRepository: asClass( require('../repositories/postgres/friendships.repository') ).scoped(), friendRequestsRepository: asClass( require('../repositories/postgres/friendRequests.repository') ).scoped(), - betsRepository: asClass( - require('../repositories/mongo/bets.repository') - ).scoped(), + betsRepository: asClass(require('../repositories/mongo/bets.repository')).scoped(), }); container.register({ - dataStitchingService: asClass( - require('../services/data-stitching.service') - ).scoped(), + dataStitchingService: asClass(require('../services/data-stitching.service')).scoped(), usersService: asClass(require('../services/users.service')).scoped(), groupsService: asClass(require('../services/groups.service')).scoped(), friendsService: asClass(require('../services/friends.service')).scoped(), @@ -70,9 +62,6 @@ function setupContainer() { groupsController: asClass(require('../controllers/groups.controller')).scoped(), usersController: asClass(require('../controllers/users.controller')).scoped(), friendsController: asClass(require('../controllers/friends.controller')).scoped(), - initialDataController: asClass( - require('../controllers/initialData.controller') - ).scoped(), }); return container; diff --git a/backend-node/init.sql b/backend-node/init.sql index ca54edc..547bc11 100644 --- a/backend-node/init.sql +++ b/backend-node/init.sql @@ -11,6 +11,7 @@ DROP TABLE IF EXISTS users CASCADE; -- users table CREATE TABLE users ( id SERIAL PRIMARY KEY, + "keycloakId" VARCHAR(255) UNIQUE, name VARCHAR(255) NOT NULL, "email" VARCHAR(255) NOT NULL UNIQUE, "avatarUrl" VARCHAR(255) NOT NULL, @@ -63,6 +64,7 @@ CREATE TABLE friend_requests ( -- User <-> Group many-to-many join table CREATE TABLE "UserGroups" ( + id SERIAL PRIMARY KEY, "userId" INTEGER NOT NULL, "groupId" INTEGER NOT NULL, CONSTRAINT usergroups_user_fk FOREIGN KEY ("userId") REFERENCES users(id) ON DELETE CASCADE, diff --git a/backend-node/middleware/auth.keycloak.js b/backend-node/middleware/auth.keycloak.js new file mode 100644 index 0000000..cf13a2d --- /dev/null +++ b/backend-node/middleware/auth.keycloak.js @@ -0,0 +1,67 @@ +const jwksClient = require('jwks-rsa'); +const jwt = require('jsonwebtoken'); + +const client = jwksClient({ + jwksUri: + process.env.KEYCLOAK_JWKS_URI || + 'http://localhost:8080/realms/scommettiapp/protocol/openid-connect/certs', + cache: true, + rateLimit: true, + jwksRequestsPerMinute: 5, +}); + +function getKey(header, callback) { + client.getSigningKey(header.kid, (err, key) => { + if (err) return callback(err); + callback(null, key.getPublicKey()); + }); +} + +module.exports = sequelize => { + const { User } = sequelize.models; + + return (req, res, next) => { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ success: false, message: 'Token mancante o non valido' }); + } + + const token = authHeader.slice(7); + + jwt.verify(token, getKey, { algorithms: ['RS256'] }, async (err, decoded) => { + if (err) { + return res.status(401).json({ success: false, message: 'Token non valido o scaduto' }); + } + + try { + const keycloakId = decoded.sub; + const name = + decoded.name || + `${decoded.given_name || ''} ${decoded.family_name || ''}`.trim() || + decoded.preferred_username || + 'Utente'; + const email = decoded.email || `${keycloakId.slice(0, 8)}@scommetti.app`; + const avatarUrl = `https://picsum.photos/seed/${keycloakId.slice(0, 8)}/200/200`; + + // Prima cerca per keycloakId (caso normale dopo il primo login) + let user = await User.findOne({ where: { keycloakId } }); + + if (!user) { + // Migrazione: l'utente potrebbe esistere già per email + // (creato prima che keycloakId fosse aggiunto al modello) + user = await User.findOne({ where: { email } }); + if (user) { + await user.update({ keycloakId }); + } else { + user = await User.create({ keycloakId, name, email, avatarUrl, score: 0 }); + } + } + + req.userId = user.id; + next(); + } catch (error) { + next(error); + } + }); + }; +}; diff --git a/backend-node/models/associations.js b/backend-node/models/associations.js index a0715d0..d05dbdc 100644 --- a/backend-node/models/associations.js +++ b/backend-node/models/associations.js @@ -1,21 +1,46 @@ -module.exports = (sequelize) => { - const { User, Group, FriendRequest, Friendship } = sequelize.models; +// eslint-disable-next-line import/no-import-module-exports +import { DataTypes } from 'sequelize'; - // User <-> Group: Many-to-Many - // A User can be in many Groups, and a Group can have many Users (members). - const UserGroup = sequelize.define('UserGroup', {}, { timestamps: false }); - User.belongsToMany(Group, { through: UserGroup, foreignKey: 'userId' }); - Group.belongsToMany(User, { through: UserGroup, as: 'members', foreignKey: 'groupId' }); +module.exports = sequelize => { + const { User, Group, FriendRequest, Friendship } = sequelize.models; - // Friendship is a direct link between two users. - // user1Id will always be less than user2Id to ensure uniqueness. - Friendship.belongsTo(User, { as: 'user1', foreignKey: 'user1Id' }); - Friendship.belongsTo(User, { as: 'user2', foreignKey: 'user2Id' }); + // User <-> Group: Many-to-Many + // Il modello dichiara esplicitamente la colonna id per evitare che sync() la cerchi + // sulla tabella UserGroups senza trovarla (causando errori su RETURNING "id"). + const UserGroup = sequelize.define( + 'UserGroup', + { + id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, + userId: { type: DataTypes.INTEGER, allowNull: false }, + groupId: { type: DataTypes.INTEGER, allowNull: false }, + }, + { timestamps: false } + ); + User.belongsToMany(Group, { through: UserGroup, foreignKey: 'userId', otherKey: 'groupId' }); + Group.belongsToMany(User, { + through: UserGroup, + as: 'members', + foreignKey: 'groupId', + otherKey: 'userId', + }); - // User <-> FriendRequest: One-to-Many - // A User can send many friend requests and receive many friend requests. - User.hasMany(FriendRequest, { as: 'sentRequests', foreignKey: 'fromUserId', onDelete: 'CASCADE' }); - User.hasMany(FriendRequest, { as: 'receivedRequests', foreignKey: 'toUserId', onDelete: 'CASCADE' }); - FriendRequest.belongsTo(User, { as: 'fromUser', foreignKey: 'fromUserId' }); - FriendRequest.belongsTo(User, { as: 'toUser', foreignKey: 'toUserId' }); + // Friendship is a direct link between two users. + // user1Id will always be less than user2Id to ensure uniqueness. + Friendship.belongsTo(User, { as: 'user1', foreignKey: 'user1Id' }); + Friendship.belongsTo(User, { as: 'user2', foreignKey: 'user2Id' }); + + // User <-> FriendRequest: One-to-Many + // A User can send many friend requests and receive many friend requests. + User.hasMany(FriendRequest, { + as: 'sentRequests', + foreignKey: 'fromUserId', + onDelete: 'CASCADE', + }); + User.hasMany(FriendRequest, { + as: 'receivedRequests', + foreignKey: 'toUserId', + onDelete: 'CASCADE', + }); + FriendRequest.belongsTo(User, { as: 'fromUser', foreignKey: 'fromUserId' }); + FriendRequest.belongsTo(User, { as: 'toUser', foreignKey: 'toUserId' }); }; diff --git a/backend-node/models/friendRequest.model.js b/backend-node/models/friendRequest.model.js index e774168..f3c7a47 100644 --- a/backend-node/models/friendRequest.model.js +++ b/backend-node/models/friendRequest.model.js @@ -1,21 +1,34 @@ const { DataTypes } = require('sequelize'); -module.exports = (sequelize) => { - const FriendRequest = sequelize.define('FriendRequest', { - id: { - type: DataTypes.INTEGER, - autoIncrement: true, - primaryKey: true +module.exports = sequelize => { + const FriendRequest = sequelize.define( + 'FriendRequest', + { + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + status: { + type: DataTypes.STRING, + defaultValue: 'PENDING', + allowNull: false, + }, + // Foreign keys 'fromUserId' and 'toUserId' are added via associations }, - status: { - type: DataTypes.STRING, - defaultValue: 'PENDING', - allowNull: false + { + // Uses default `createdAt` and `updatedAt` timestamps from Sequelize + tableName: 'friend_requests', } - // Foreign keys 'fromUserId' and 'toUserId' are added via associations - }, { - // Uses default `createdAt` and `updatedAt` timestamps from Sequelize - tableName: 'friend_requests' - }); + ); + + FriendRequest.prototype.toJSON = function () { + const values = { ...this.get() }; + if (values.id !== undefined) values.id = String(values.id); + if (values.fromUserId !== undefined) values.fromUserId = String(values.fromUserId); + if (values.toUserId !== undefined) values.toUserId = String(values.toUserId); + return values; + }; + return FriendRequest; }; diff --git a/backend-node/models/group.model.js b/backend-node/models/group.model.js index 92fd610..f67de5d 100644 --- a/backend-node/models/group.model.js +++ b/backend-node/models/group.model.js @@ -1,19 +1,30 @@ const { DataTypes } = require('sequelize'); -module.exports = (sequelize) => { - const Group = sequelize.define('Group', { - id: { - type: DataTypes.INTEGER, - autoIncrement: true, - primaryKey: true +module.exports = sequelize => { + const Group = sequelize.define( + 'Group', + { + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + name: { + type: DataTypes.STRING, + allowNull: false, + }, }, - name: { - type: DataTypes.STRING, - allowNull: false + { + timestamps: false, + tableName: 'groups', } - }, { - timestamps: false, - tableName: 'groups' - }); + ); + + Group.prototype.toJSON = function () { + const values = { ...this.get() }; + if (values.id !== undefined) values.id = String(values.id); + return values; + }; + return Group; }; diff --git a/backend-node/models/user.model.js b/backend-node/models/user.model.js index 4ae6311..3cece8f 100644 --- a/backend-node/models/user.model.js +++ b/backend-node/models/user.model.js @@ -1,35 +1,51 @@ const { DataTypes } = require('sequelize'); -module.exports = (sequelize) => { - const User = sequelize.define('User', { - id: { - type: DataTypes.INTEGER, - autoIncrement: true, - primaryKey: true +module.exports = sequelize => { + const User = sequelize.define( + 'User', + { + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + keycloakId: { + type: DataTypes.STRING, + allowNull: true, + unique: true, + }, + name: { + type: DataTypes.STRING, + allowNull: false, + }, + email: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + validate: { + isEmail: true, + }, + }, + avatarUrl: { + type: DataTypes.STRING, + allowNull: false, + }, + score: { + type: DataTypes.INTEGER, + defaultValue: 0, + }, }, - name: { - type: DataTypes.STRING, - allowNull: false - }, - email: { - type: DataTypes.STRING, - allowNull: false, - unique: true, - validate: { - isEmail: true - } - }, - avatarUrl: { - type: DataTypes.STRING, - allowNull: false - }, - score: { - type: DataTypes.INTEGER, - defaultValue: 0 - }, - }, { - timestamps: false, - tableName: 'users' - }); + { + timestamps: false, + tableName: 'users', + } + ); + + User.prototype.toJSON = function () { + const values = { ...this.get() }; + if (values.id !== undefined) values.id = String(values.id); + return values; + }; + return User; }; diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index d755889..9d3b90a 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -13,6 +13,8 @@ "cors": "^2.8.5", "express": "^4.19.2", "joi": "^17.12.1", + "jsonwebtoken": "^9.0.2", + "jwks-rsa": "^3.1.0", "mongoose": "^8.4.0", "pg": "^8.11.5", "pg-hstore": "^2.3.4", @@ -294,6 +296,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -653,6 +665,12 @@ "node": ">=16.20.1" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1003,6 +1021,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -2640,6 +2667,15 @@ "@sideway/pinpoint": "^2.0.0" } }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", @@ -2687,6 +2723,94 @@ "json5": "lib/cli.js" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jwks-rsa": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.2.tgz", + "integrity": "sha512-BqTyEDV+lS8F2trk3A+qJnxV5Q9EqKCBJOPti3W97r7qTympCZjb7h2X6f2kc+0K3rsSTY1/6YG2eaXKoj497w==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "^9.0.4", + "debug": "^4.3.4", + "jose": "^4.15.4", + "limiter": "^1.1.5", + "lru-memoizer": "^2.2.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/jwks-rsa/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/jwks-rsa/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/kareem": { "version": "2.6.3", "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.6.3.tgz", @@ -2720,6 +2844,11 @@ "node": ">= 0.8.0" } }, + "node_modules/limiter": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", + "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -2742,6 +2871,48 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -2749,6 +2920,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/lower-case": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", @@ -2758,6 +2935,28 @@ "tslib": "^2.0.3" } }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/lru-memoizer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz", + "integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==", + "license": "MIT", + "dependencies": { + "lodash.clonedeep": "^4.5.0", + "lru-cache": "6.0.0" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -4763,6 +4962,12 @@ "node": ">=0.4" } }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/backend-node/package.json b/backend-node/package.json index 989d71a..6ef614b 100644 --- a/backend-node/package.json +++ b/backend-node/package.json @@ -30,6 +30,8 @@ "cors": "^2.8.5", "express": "^4.19.2", "joi": "^17.12.1", + "jsonwebtoken": "^9.0.2", + "jwks-rsa": "^3.1.0", "mongoose": "^8.4.0", "pg": "^8.11.5", "pg-hstore": "^2.3.4", diff --git a/backend-node/repositories/postgres/friendRequests.repository.js b/backend-node/repositories/postgres/friendRequests.repository.js index dd40c91..2cb0c1a 100644 --- a/backend-node/repositories/postgres/friendRequests.repository.js +++ b/backend-node/repositories/postgres/friendRequests.repository.js @@ -1,5 +1,5 @@ -const { NotFoundError, DatabaseError } = require('../../shared/errors'); const { Op } = require('sequelize'); +const { NotFoundError, DatabaseError } = require('../../shared/errors'); class FriendRequestsRepository { constructor({ sequelize }) { @@ -30,7 +30,7 @@ class FriendRequestsRepository { try { return await this.FriendRequest.findAll({ where: { - toUserId: userId, + [Op.or]: [{ toUserId: userId }, { fromUserId: userId }], status: 'PENDING', }, include: [ @@ -39,10 +39,7 @@ class FriendRequestsRepository { ], }); } catch (error) { - throw new DatabaseError( - `Failed to fetch pending requests for user ${userId}`, - error - ); + throw new DatabaseError(`Failed to fetch pending requests for user ${userId}`, error); } } @@ -64,11 +61,12 @@ class FriendRequestsRepository { async create(fromUserId, toUserId) { try { - return await this.FriendRequest.create({ + const created = await this.FriendRequest.create({ fromUserId, toUserId, status: 'PENDING', }); + return await this.findById(created.id); } catch (error) { throw new DatabaseError('Failed to create friend request', error); } diff --git a/backend-node/repositories/postgres/groups.repository.js b/backend-node/repositories/postgres/groups.repository.js index 992ecdd..9dc01aa 100644 --- a/backend-node/repositories/postgres/groups.repository.js +++ b/backend-node/repositories/postgres/groups.repository.js @@ -1,3 +1,4 @@ +const { Op } = require('sequelize'); const { NotFoundError, DatabaseError } = require('../../shared/errors'); class GroupsRepository { @@ -15,6 +16,29 @@ class GroupsRepository { } } + async findByUserId(userId) { + try { + return await this.Group.findAll({ + include: [ + { + model: this.User, + as: 'members', + through: { attributes: [] }, + }, + ], + where: { + id: { + [Op.in]: this.sequelize.literal( + `(SELECT "groupId" FROM "UserGroups" WHERE "userId" = ${parseInt(userId, 10)})` + ), + }, + }, + }); + } catch (error) { + throw new DatabaseError('Failed to fetch groups for user', error); + } + } + async findById(id, includeMembers = false) { try { const options = {}; diff --git a/backend-node/routes/api.js b/backend-node/routes/api.js index 56b58fb..4e63abf 100644 --- a/backend-node/routes/api.js +++ b/backend-node/routes/api.js @@ -1,31 +1,30 @@ const express = require('express'); // Il router è ora una factory function che riceve l'istanza di sequelize -module.exports = (sequelize) => { - const router = express.Router(); - - // --- Middleware per simulare un utente loggato --- - const { User } = sequelize.models; - const MOCK_USER_NAME = 'Mario Rossi'; - let MOCK_USER_ID = null; +module.exports = sequelize => { + const router = express.Router(); - router.use(async (req, res, next) => { - if (!MOCK_USER_ID) { - const user = await User.findOne({ where: { name: MOCK_USER_NAME } }); - if (user) { - MOCK_USER_ID = user.id; - } - } - req.userId = MOCK_USER_ID; - next(); - }); + // --- Middleware per simulare un utente loggato --- + const { User } = sequelize.models; + const MOCK_USER_NAME = 'Mario Rossi'; + let MOCK_USER_ID = null; - // --- Monta i router da file separati --- - router.use(require('./initial-data.routes')(sequelize)); - router.use('/bets', require('./bets.routes')(sequelize)); - router.use('/groups', require('./groups.routes')(sequelize)); - router.use('/friend-requests', require('./friends.routes')(sequelize)); - router.use('/users', require('./users.routes')(sequelize)); - - return router; + router.use(async (req, res, next) => { + if (!MOCK_USER_ID) { + const user = await User.findOne({ where: { name: MOCK_USER_NAME } }); + if (user) { + MOCK_USER_ID = user.id; + } + } + req.userId = MOCK_USER_ID; + next(); + }); + + // --- Monta i router da file separati --- + router.use('/bets', require('./bets.routes')(sequelize)); + router.use('/groups', require('./groups.routes')(sequelize)); + router.use('/friend-requests', require('./friends.routes')(sequelize)); + router.use('/users', require('./users.routes')(sequelize)); + + return router; }; diff --git a/backend-node/routes/initial-data.routes.js b/backend-node/routes/initial-data.routes.js deleted file mode 100644 index 42a5924..0000000 --- a/backend-node/routes/initial-data.routes.js +++ /dev/null @@ -1,59 +0,0 @@ -const express = require('express'); -const router = express.Router(); -const { Op } = require('sequelize'); - -// Modello Mongoose per le Scommesse -const Bet = require('../models/bet.model'); - -module.exports = (sequelize) => { - const { User, Friendship, FriendRequest } = sequelize.models; - const { populateBetsWithUsers } = require('../utils/populateBets')(sequelize); - - // GET /api/initial-data - router.get('/initial-data', async (req, res) => { - try { - // --- Query PostgreSQL --- - const currentUser = await User.findByPk(req.userId); - const users = await User.findAll(); - - const friendships = await Friendship.findAll({ - where: { [Op.or]: [{ user1Id: req.userId }, { user2Id: req.userId }] }, - include: [{ model: User, as: 'user1' }, { model: User, as: 'user2' }] - }); - const friends = friendships.map(f => (f.user1Id === req.userId ? f.user2 : f.user1)); - - const receivedFriendRequests = await FriendRequest.findAll({ - where: { toUserId: req.userId, status: 'PENDING' }, - include: [{ model: User, as: 'fromUser' }, { model: User, as: 'toUser' }] - }); - - const sentFriendRequests = await FriendRequest.findAll({ - where: { fromUserId: req.userId, status: 'PENDING' }, - include: [{ model: User, as: 'fromUser' }, { model: User, as: 'toUser' }] - }); - - const userGroups = await currentUser.getGroups({ - include: [{ model: User, as: 'members', through: { attributes: [] } }] - }); - - // --- Query MongoDB --- - const mongoBets = await Bet.find({ 'participants.user': req.userId }).sort({ startDate: -1 }); - const mongoPublicBets = await Bet.find({ isPublic: true, 'participants.user': { $ne: req.userId } }); - - // --- Data Stitching --- - const bets = await populateBetsWithUsers(mongoBets); - const publicBets = await populateBetsWithUsers(mongoPublicBets); - - res.json({ - currentUser, users, friends, receivedFriendRequests, sentFriendRequests, - bets, publicBets, groups: userGroups - }); - - } catch (error) { - console.error('Error fetching initial data:', error); - res.status(500).json({ message: 'Error fetching initial data', error: error.message }); - } - }); - - return router; -}; diff --git a/backend-node/routes/v1/bets.routes.js b/backend-node/routes/v1/bets.routes.js index 2487afb..369667c 100644 --- a/backend-node/routes/v1/bets.routes.js +++ b/backend-node/routes/v1/bets.routes.js @@ -1,14 +1,13 @@ const express = require('express'); const { validate } = require('../../middleware/validation'); -const { - createBetSchema, - updateBetSchema, -} = require('../../shared/validators/schemas/bet.schema'); +const { createBetSchema, updateBetSchema } = require('../../shared/validators/schemas/bet.schema'); module.exports = ({ betsController }) => { const router = express.Router(); const controller = { + getUserBets: (req, res, next) => betsController.getUserBets(req, res, next), + getPublicBets: (req, res, next) => betsController.getPublicBets(req, res, next), getAllBets: (req, res, next) => betsController.getAllBets(req, res, next), getBetById: (req, res, next) => betsController.getBetById(req, res, next), createBet: (req, res, next) => betsController.createBet(req, res, next), @@ -16,6 +15,8 @@ module.exports = ({ betsController }) => { deleteBet: (req, res, next) => betsController.deleteBet(req, res, next), }; + router.get('/user', controller.getUserBets); + router.get('/public', controller.getPublicBets); router.get('/', controller.getAllBets); router.get('/:id', controller.getBetById); router.post('/', validate(createBetSchema), controller.createBet); diff --git a/backend-node/routes/v1/index.js b/backend-node/routes/v1/index.js index 494739a..af1d28f 100644 --- a/backend-node/routes/v1/index.js +++ b/backend-node/routes/v1/index.js @@ -1,6 +1,6 @@ const express = require('express'); -module.exports = (container) => { +module.exports = container => { const router = express.Router(); const controllers = { @@ -8,14 +8,12 @@ module.exports = (container) => { betsController: container.resolve('betsController'), usersController: container.resolve('usersController'), friendsController: container.resolve('friendsController'), - initialDataController: container.resolve('initialDataController'), }; router.use('/groups', require('./groups.routes')(controllers)); router.use('/bets', require('./bets.routes')(controllers)); router.use('/users', require('./users.routes')(controllers)); router.use('/friend-requests', require('./friends.routes')(controllers)); - router.use('/initial-data', require('./initialData.routes')(controllers)); return router; }; diff --git a/backend-node/routes/v1/initialData.routes.js b/backend-node/routes/v1/initialData.routes.js deleted file mode 100644 index b00a838..0000000 --- a/backend-node/routes/v1/initialData.routes.js +++ /dev/null @@ -1,13 +0,0 @@ -const express = require('express'); - -module.exports = ({ initialDataController }) => { - const router = express.Router(); - - const controller = { - getInitialData: (req, res, next) => initialDataController.getInitialData(req, res, next), - }; - - router.get('/', controller.getInitialData); - - return router; -}; diff --git a/backend-node/routes/v1/users.routes.js b/backend-node/routes/v1/users.routes.js index 5e87275..c6229c1 100644 --- a/backend-node/routes/v1/users.routes.js +++ b/backend-node/routes/v1/users.routes.js @@ -6,9 +6,11 @@ module.exports = ({ usersController }) => { const router = express.Router(); const controller = { + getCurrentUser: (req, res, next) => usersController.getCurrentUser(req, res, next), updateCurrentUser: (req, res, next) => usersController.updateCurrentUser(req, res, next), }; + router.get('/current', controller.getCurrentUser); router.put('/current', validate(updateUserSchema), controller.updateCurrentUser); return router; diff --git a/backend-node/server.js b/backend-node/server.js index 9d946a1..bd03d93 100644 --- a/backend-node/server.js +++ b/backend-node/server.js @@ -4,8 +4,7 @@ const cors = require('cors'); const config = require('./config'); const setupContainer = require('./di/container'); const errorHandler = require('./middleware/errorHandler'); -const mockAuthMiddleware = require('./middleware/auth.mock'); -const { seedDatabase } = require('./seed'); +const keycloakAuthMiddleware = require('./middleware/auth.keycloak'); const legacyApiRoutes = require('./routes/api'); @@ -21,20 +20,22 @@ const sequelize = container.resolve('sequelize'); mongoose .connect(config.database.mongo.uri, config.database.mongo.options) .then(() => console.log('Connected to MongoDB')) - .catch((err) => { + .catch(err => { console.error('MongoDB connection error:', err); process.exit(1); }); -app.use('/api', mockAuthMiddleware(sequelize)); +app.use('/api', keycloakAuthMiddleware(sequelize)); app.use('/api/legacy', legacyApiRoutes(sequelize)); const v1Routes = require('./routes/v1'); + app.use('/api/v1', v1Routes(container)); app.use('/api', v1Routes(container)); const healthRoutes = require('./routes/health.routes'); + app.use('/', healthRoutes({ sequelize, mongoose })); app.use(errorHandler); @@ -43,11 +44,10 @@ sequelize .authenticate() .then(() => { console.log('Connected to PostgreSQL'); - return sequelize.sync({ force: true }); + return sequelize.sync({ alter: true }); }) .then(() => { console.log('PostgreSQL models synchronized'); - return seedDatabase(sequelize); }) .then(() => { app.listen(config.port, () => { @@ -58,7 +58,7 @@ sequelize console.log(`Health check available at /health`); }); }) - .catch((err) => { + .catch(err => { console.error('PostgreSQL connection or sync error:', err); process.exit(1); }); diff --git a/backend-node/services/friends.service.js b/backend-node/services/friends.service.js index 16af166..6dd50cc 100644 --- a/backend-node/services/friends.service.js +++ b/backend-node/services/friends.service.js @@ -1,13 +1,8 @@ -const { ValidationError, ConflictError } = require('../shared/errors'); const { Op } = require('sequelize'); +const { ValidationError, ConflictError } = require('../shared/errors'); class FriendsService { - constructor({ - friendshipsRepository, - friendRequestsRepository, - usersRepository, - sequelize, - }) { + constructor({ friendshipsRepository, friendRequestsRepository, usersRepository, sequelize }) { this.friendshipsRepository = friendshipsRepository; this.friendRequestsRepository = friendRequestsRepository; this.usersRepository = usersRepository; @@ -33,9 +28,7 @@ class FriendsService { }); if (!toUser) { - throw new ValidationError( - 'User not found with provided name or email' - ); + throw new ValidationError('User not found with provided name or email'); } } @@ -43,18 +36,12 @@ class FriendsService { throw new ValidationError('Cannot send friend request to yourself'); } - const friendshipExists = await this.friendshipsRepository.exists( - fromUserId, - toUser.id - ); + const friendshipExists = await this.friendshipsRepository.exists(fromUserId, toUser.id); if (friendshipExists) { throw new ConflictError('You are already friends with this user'); } - const existingRequest = await this.friendRequestsRepository.findExisting( - fromUserId, - toUser.id - ); + const existingRequest = await this.friendRequestsRepository.findExisting(fromUserId, toUser.id); if (existingRequest) { throw new ConflictError('Friend request already exists'); } @@ -73,6 +60,8 @@ class FriendsService { throw new ValidationError('This request has already been processed'); } + const newFriend = request.fromUser; + const t = await this.sequelize.transaction(); try { await this.friendRequestsRepository.updateStatus(requestId, 'ACCEPTED'); @@ -80,7 +69,7 @@ class FriendsService { await this.friendRequestsRepository.delete(requestId); await t.commit(); - return { message: 'Friend request accepted successfully' }; + return { message: 'Friend request accepted successfully', newFriend }; } catch (error) { await t.rollback(); throw error; diff --git a/backend-node/services/groups.service.js b/backend-node/services/groups.service.js index 613a222..9011058 100644 --- a/backend-node/services/groups.service.js +++ b/backend-node/services/groups.service.js @@ -6,16 +6,8 @@ class GroupsService { this.usersRepository = usersRepository; } - async getAllGroups() { - return await this.groupsRepository.findAll({ - include: [ - { - model: this.usersRepository.User, - as: 'members', - through: { attributes: [] }, - }, - ], - }); + async getAllGroups(userId) { + return await this.groupsRepository.findByUserId(userId); } async getGroupById(groupId) { diff --git a/docker-compose.yml b/docker-compose.yml index 7b4bbbe..d1026ca 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,6 +42,57 @@ services: timeout: 5s retries: 5 + # Keycloak Database + keycloak-db: + image: postgres:15-alpine + container_name: scommetti-keycloak-db + environment: + POSTGRES_DB: keycloak + POSTGRES_USER: keycloak + POSTGRES_PASSWORD: keycloak + volumes: + - keycloak_db_data:/var/lib/postgresql/data + networks: + - scommetti-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U keycloak -d keycloak"] + interval: 10s + timeout: 5s + retries: 5 + + # Keycloak Auth Server + keycloak: + image: quay.io/keycloak/keycloak:23.0.0 + container_name: scommetti-keycloak + command: start-dev --import-realm + environment: + KC_DB: postgres + KC_DB_URL: jdbc:postgresql://keycloak-db:5432/keycloak + KC_DB_USERNAME: keycloak + KC_DB_PASSWORD: keycloak + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: admin + KC_HEALTH_ENABLED: "true" + KC_METRICS_ENABLED: "true" + KC_HOSTNAME_STRICT: "false" + KC_HTTP_ENABLED: "true" + ports: + - "8080:8080" + volumes: + - ./keycloak/realm-export.json:/opt/keycloak/data/import/realm.json:ro + - ./keycloak/themes/scommettiapp:/opt/keycloak/themes/scommettiapp:ro + depends_on: + keycloak-db: + condition: service_healthy + networks: + - scommetti-network + healthcheck: + test: ["CMD-SHELL", "exec 3<>/dev/tcp/localhost/8080 && printf 'GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && timeout 5 cat <&3 | grep -q 'UP'"] + interval: 15s + timeout: 15s + retries: 10 + start_period: 90s + # Backend Node.js backend: build: @@ -53,6 +104,7 @@ services: PORT: 3000 POSTGRES_URI: postgres://admin:admin@postgres:5432/amici_scommesse MONGO_URI: mongodb://mongodb:27017/amici-scommesse + KEYCLOAK_JWKS_URI: http://keycloak:8080/realms/scommettiapp/protocol/openid-connect/certs ports: - "3000:3000" volumes: @@ -63,6 +115,8 @@ services: condition: service_healthy mongodb: condition: service_healthy + keycloak: + condition: service_healthy networks: - scommetti-network command: npm start @@ -84,9 +138,9 @@ services: - /app/.angular depends_on: - backend + - keycloak networks: - scommetti-network - command: npm run dev restart: unless-stopped volumes: @@ -94,6 +148,8 @@ volumes: driver: local mongodb_data: driver: local + keycloak_db_data: + driver: local networks: scommetti-network: diff --git a/frontend-angular/Dockerfile b/frontend-angular/Dockerfile index f188921..7e3a1c4 100644 --- a/frontend-angular/Dockerfile +++ b/frontend-angular/Dockerfile @@ -8,7 +8,7 @@ WORKDIR /app COPY package*.json ./ # Install dependencies -RUN npm ci +RUN npm install # Copy application code COPY . . diff --git a/frontend-angular/angular.json b/frontend-angular/angular.json index e179ed3..3612cc8 100644 --- a/frontend-angular/angular.json +++ b/frontend-angular/angular.json @@ -34,7 +34,7 @@ "serve": { "builder": "@angular/build:dev-server", "options": { - "port": 3000 + "port": 4200 }, "configurations": { "production": { diff --git a/frontend-angular/index.tsx b/frontend-angular/index.tsx index 3b8c372..354dc34 100644 --- a/frontend-angular/index.tsx +++ b/frontend-angular/index.tsx @@ -1,15 +1,36 @@ import '@angular/compiler'; import { bootstrapApplication } from '@angular/platform-browser'; import { provideZonelessChangeDetection } from '@angular/core'; -import { provideHttpClient, withFetch } from '@angular/common/http'; +import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http'; +import Keycloak from 'keycloak-js'; import { AppComponent } from './src/app.component'; +import { KEYCLOAK_INSTANCE } from './src/core/tokens/keycloak.token'; +import { authInterceptor } from './src/core/interceptors/auth.interceptor'; -bootstrapApplication(AppComponent, { - providers: [ - provideZonelessChangeDetection(), - provideHttpClient(withFetch()), - ], -}).catch(err => console.error(err)); +const keycloak = new Keycloak({ + url: 'http://localhost:8080', + realm: 'scommettiapp', + clientId: 'scommetti-frontend', +}); -// AI Studio always uses an `index.tsx` file for all project types. \ No newline at end of file +keycloak.init({ + onLoad: 'login-required', + checkLoginIframe: false, + pkceMethod: 'S256', +}).then(authenticated => { + if (!authenticated) { + keycloak.login(); + return; + } + + bootstrapApplication(AppComponent, { + providers: [ + provideZonelessChangeDetection(), + provideHttpClient(withFetch(), withInterceptors([authInterceptor])), + { provide: KEYCLOAK_INSTANCE, useValue: keycloak }, + ], + }).catch(err => console.error(err)); +}).catch(err => { + console.error('Keycloak initialization failed:', err); +}); diff --git a/frontend-angular/package-lock.json b/frontend-angular/package-lock.json index 5cfbc87..8e2dc64 100644 --- a/frontend-angular/package-lock.json +++ b/frontend-angular/package-lock.json @@ -15,6 +15,7 @@ "@angular/compiler-cli": "^20.3.0", "@angular/core": "^20.3.0", "@angular/platform-browser": "^20.3.0", + "keycloak-js": "^24.0.0", "rxjs": "^7.8.2", "tailwindcss": "latest" }, @@ -4931,6 +4932,12 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/js-sha256": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.11.1.tgz", + "integrity": "sha512-o6WSo/LUvY2uC4j7mO50a2ms7E/EAdbP0swigLV+nzHKTTaYnaLIWJ02VdXrsJX0vGedDESQnLsOekr94ryfjg==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4991,6 +4998,25 @@ ], "license": "MIT" }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/keycloak-js": { + "version": "24.0.5", + "resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-24.0.5.tgz", + "integrity": "sha512-VQOSn3j13DPB6OuavKAq+sRjDERhIKrXgBzekoHRstifPuyULILguugX6yxRUYFSpn3OMYUXmSX++tkdCupOjA==", + "license": "Apache-2.0", + "dependencies": { + "js-sha256": "^0.11.0", + "jwt-decode": "^4.0.0" + } + }, "node_modules/listr2": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.1.tgz", diff --git a/frontend-angular/package.json b/frontend-angular/package.json index b86b509..0ebe46c 100644 --- a/frontend-angular/package.json +++ b/frontend-angular/package.json @@ -17,6 +17,7 @@ "@angular/build": "^20.3.0", "@angular/cli": "^20.3.0", "@angular/compiler-cli": "^20.3.0", + "keycloak-js": "^24.0.0", "tailwindcss": "latest" }, "devDependencies": { diff --git a/frontend-angular/src/app.component.ts b/frontend-angular/src/app.component.ts index 68f5de3..8e7bd3b 100644 --- a/frontend-angular/src/app.component.ts +++ b/frontend-angular/src/app.component.ts @@ -1,10 +1,11 @@ -import { Component, ChangeDetectionStrategy, signal } from '@angular/core'; +import { Component, ChangeDetectionStrategy, signal, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { HomeComponent } from './features/home/home.component'; import { DiscoverComponent } from './features/discover/discover.component'; import { GroupsComponent } from './features/groups/groups.component'; import { ProfileComponent } from './features/profile/profile.component'; import { CreateBetComponent } from './features/bets/create-bet.component'; +import { DataService } from './core/services/data.service'; type View = 'home' | 'discover' | 'groups' | 'profile' | 'create-bet'; @@ -23,9 +24,15 @@ type View = 'home' | 'discover' | 'groups' | 'profile' | 'create-bet'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class AppComponent { + private dataService = inject(DataService); + currentView = signal('home'); private previousView = signal('home'); + constructor() { + this.dataService.loadCurrentUser(); + } + setView(view: View) { if (view === 'create-bet') { this.previousView.set(this.currentView()); @@ -38,4 +45,4 @@ export class AppComponent { onBetActionClosed() { this.currentView.set(this.previousView()); } -} \ No newline at end of file +} diff --git a/frontend-angular/src/core/interceptors/auth.interceptor.ts b/frontend-angular/src/core/interceptors/auth.interceptor.ts new file mode 100644 index 0000000..f94cc21 --- /dev/null +++ b/frontend-angular/src/core/interceptors/auth.interceptor.ts @@ -0,0 +1,18 @@ +import { HttpInterceptorFn } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { from, switchMap } from 'rxjs'; +import { AuthService } from '../services/auth.service'; + +export const authInterceptor: HttpInterceptorFn = (req, next) => { + const authService = inject(AuthService); + + return from(authService.getValidToken()).pipe( + switchMap(token => { + if (!token) return next(req); + const authReq = req.clone({ + headers: req.headers.set('Authorization', `Bearer ${token}`) + }); + return next(authReq); + }) + ); +}; diff --git a/frontend-angular/src/core/models/api-responses.model.ts b/frontend-angular/src/core/models/api-responses.model.ts index 891a0cc..b2e54b3 100644 --- a/frontend-angular/src/core/models/api-responses.model.ts +++ b/frontend-angular/src/core/models/api-responses.model.ts @@ -1,8 +1,3 @@ -import { User } from './user.model'; -import { Group } from './group.model'; -import { Bet } from './bet.model'; -import { FriendRequest } from './friend-request.model'; - export interface SuccessResponse { success: boolean; message?: string; @@ -21,16 +16,3 @@ export interface ErrorResponse { errors?: ErrorDetail[]; timestamp: string; } - -export interface InitialData { - currentUser: User; - users: User[]; - friends: User[]; - friendRequests: FriendRequest[]; - receivedFriendRequests?: FriendRequest[]; // Legacy or convenience - sentFriendRequests?: FriendRequest[]; // Legacy or convenience - groups: Group[]; - userBets: Bet[]; - bets?: Bet[]; // Compatibility with data.service - publicBets: Bet[]; -} diff --git a/frontend-angular/src/core/services/api/bet-api.service.ts b/frontend-angular/src/core/services/api/bet-api.service.ts index 83394a0..245f77f 100644 --- a/frontend-angular/src/core/services/api/bet-api.service.ts +++ b/frontend-angular/src/core/services/api/bet-api.service.ts @@ -21,6 +21,18 @@ export class BetApiService { private http = inject(HttpClient); private readonly apiUrl = 'http://localhost:3000/api/v1/bets'; + getUserBets(): Observable { + return this.http.get>(`${this.apiUrl}/user`).pipe( + map(res => res.data.map(mapBet)) + ); + } + + getPublicBets(): Observable { + return this.http.get>(`${this.apiUrl}/public`).pipe( + map(res => res.data.map(mapBet)) + ); + } + getBets(): Observable { return this.http.get>(this.apiUrl).pipe( map(res => res.data.map(mapBet)) diff --git a/frontend-angular/src/core/services/api/friend-request-api.service.ts b/frontend-angular/src/core/services/api/friend-request-api.service.ts index 316d134..ac114e0 100644 --- a/frontend-angular/src/core/services/api/friend-request-api.service.ts +++ b/frontend-angular/src/core/services/api/friend-request-api.service.ts @@ -13,6 +13,18 @@ export class FriendRequestApiService { private http = inject(HttpClient); private readonly apiUrl = 'http://localhost:3000/api/v1/friend-requests'; + getFriends(): Observable { + return this.http.get>(this.apiUrl).pipe( + map(res => res.data) + ); + } + + getPendingRequests(): Observable { + return this.http.get>(`${this.apiUrl}/pending`).pipe( + map(res => res.data) + ); + } + sendRequest(request: SendFriendRequestRequest): Observable { return this.http.post>(this.apiUrl, request).pipe( map(res => res.data) diff --git a/frontend-angular/src/core/services/api/user-api.service.ts b/frontend-angular/src/core/services/api/user-api.service.ts index 455c105..b98a385 100644 --- a/frontend-angular/src/core/services/api/user-api.service.ts +++ b/frontend-angular/src/core/services/api/user-api.service.ts @@ -2,24 +2,8 @@ import { inject, Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; import { User, UpdateUserRequest } from '../../models/user.model'; -import { SuccessResponse, InitialData } from '../../models/api-responses.model'; +import { SuccessResponse } from '../../models/api-responses.model'; import { map } from 'rxjs/operators'; -import { toDate } from '../../utils/type-guards'; - -function mapInitialData(data: InitialData): InitialData { - const mapBet = (bet: any) => ({ - ...bet, - startDate: toDate(bet.startDate)!, - endDate: toDate(bet.endDate) - }); - - return { - ...data, - userBets: (data.userBets || []).map(mapBet), - publicBets: (data.publicBets || []).map(mapBet), - bets: (data.bets || []).map(mapBet) - }; -} @Injectable({ providedIn: 'root' @@ -28,9 +12,9 @@ export class UserApiService { private http = inject(HttpClient); private readonly apiUrl = 'http://localhost:3000/api/v1'; - getInitialData(): Observable { - return this.http.get>(`${this.apiUrl}/initial-data`).pipe( - map(res => mapInitialData(res.data)) + getCurrentUser(): Observable { + return this.http.get>(`${this.apiUrl}/users/current`).pipe( + map(res => res.data) ); } @@ -39,6 +23,4 @@ export class UserApiService { map(res => res.data) ); } - - // Potential future: getProfile(id), etc. } diff --git a/frontend-angular/src/core/services/auth.service.ts b/frontend-angular/src/core/services/auth.service.ts new file mode 100644 index 0000000..18e7ebf --- /dev/null +++ b/frontend-angular/src/core/services/auth.service.ts @@ -0,0 +1,27 @@ +import { Injectable, inject } from '@angular/core'; +import { KEYCLOAK_INSTANCE } from '../tokens/keycloak.token'; + +@Injectable({ + providedIn: 'root' +}) +export class AuthService { + private keycloak = inject(KEYCLOAK_INSTANCE); + + async getValidToken(): Promise { + try { + await this.keycloak.updateToken(30); + } catch { + this.keycloak.login(); + return ''; + } + return this.keycloak.token!; + } + + get tokenParsed() { + return this.keycloak.tokenParsed; + } + + logout() { + this.keycloak.logout({ redirectUri: window.location.origin }); + } +} diff --git a/frontend-angular/src/core/services/data.service.ts b/frontend-angular/src/core/services/data.service.ts index 28a4185..126268a 100644 --- a/frontend-angular/src/core/services/data.service.ts +++ b/frontend-angular/src/core/services/data.service.ts @@ -18,7 +18,6 @@ export class DataService { private userApiService = inject(UserApiService); private friendRequestApiService = inject(FriendRequestApiService); - private readonly _users = signal([]); private readonly _bets = signal([]); private readonly _groups = signal([]); private readonly _friends = signal([]); @@ -47,26 +46,64 @@ export class DataService { readonly getPublicBets = computed(() => this._publicBets()); - constructor() { - this._initializeData(); + // --- Load methods: chiamati da ogni componente al momento della visualizzazione --- + + async loadCurrentUser(): Promise { + try { + const user = await firstValueFrom(this.userApiService.getCurrentUser()); + this.currentUser.set(user); + } catch (error) { + console.error('Failed to load current user', error); + } } - private _initializeData() { - this.userApiService.getInitialData().subscribe({ - next: data => { - this.currentUser.set(data.currentUser); - this._users.set(data.users); - this._friends.set(data.friends); - // Combine received and sent requests if they come separated, or use the array if unified - this._friendRequests.set(data.friendRequests || []); - this._bets.set(data.userBets || data.bets || []); - this._publicBets.set(data.publicBets); - this._groups.set(data.groups); - }, - error: err => console.error("Failed to load initial data", err) - }); + async loadUserBets(): Promise { + try { + const bets = await firstValueFrom(this.betApiService.getUserBets()); + this._bets.set(bets); + } catch (error) { + console.error('Failed to load user bets', error); + } } + async loadPublicBets(): Promise { + try { + const bets = await firstValueFrom(this.betApiService.getPublicBets()); + this._publicBets.set(bets); + } catch (error) { + console.error('Failed to load public bets', error); + } + } + + async loadFriends(): Promise { + try { + const friends = await firstValueFrom(this.friendRequestApiService.getFriends()); + this._friends.set(friends); + } catch (error) { + console.error('Failed to load friends', error); + } + } + + async loadFriendRequests(): Promise { + try { + const requests = await firstValueFrom(this.friendRequestApiService.getPendingRequests()); + this._friendRequests.set(requests); + } catch (error) { + console.error('Failed to load friend requests', error); + } + } + + async loadGroups(): Promise { + try { + const groups = await firstValueFrom(this.groupApiService.getGroups()); + this._groups.set(groups); + } catch (error) { + console.error('Failed to load groups', error); + } + } + + // --- Utility --- + getGroupById(id: string): Group | undefined { return this._groups().find(g => g.id === id); } @@ -75,6 +112,8 @@ export class DataService { return this._bets().filter(b => b.groupId === groupId); } + // --- Mutations --- + async sendFriendRequest(identifier: string): Promise<{ success: boolean, message: string }> { try { const newRequest = await firstValueFrom(this.friendRequestApiService.sendRequest({ identifier })); @@ -90,7 +129,7 @@ export class DataService { await firstValueFrom(this.friendRequestApiService.cancelRequest(requestId)); this._friendRequests.update(reqs => reqs.filter(r => r.id !== requestId)); } catch (error) { - console.error("Failed to cancel friend request", error); + console.error('Failed to cancel friend request', error); } } @@ -100,7 +139,7 @@ export class DataService { this._friends.update(f => [...f, newFriend]); this._friendRequests.update(reqs => reqs.filter(r => r.id !== request.id)); } catch (error) { - console.error("Failed to accept friend request", error); + console.error('Failed to accept friend request', error); } } @@ -109,7 +148,7 @@ export class DataService { await firstValueFrom(this.friendRequestApiService.declineRequest(requestId)); this._friendRequests.update(reqs => reqs.filter(r => r.id !== requestId)); } catch (error) { - console.error("Failed to decline friend request", error); + console.error('Failed to decline friend request', error); } } @@ -119,7 +158,7 @@ export class DataService { const newGroup = await firstValueFrom(this.groupApiService.createGroup({ name, members: memberIds })); this._groups.update(groups => [...groups, newGroup]); } catch (error) { - console.error("Failed to add group", error); + console.error('Failed to add group', error); } } @@ -128,7 +167,7 @@ export class DataService { const updatedGroup = await firstValueFrom(this.groupApiService.removeMember(groupId, userId)); this._groups.update(groups => groups.map(g => g.id === groupId ? updatedGroup : g)); } catch (error) { - console.error("Failed to remove user from group", error); + console.error('Failed to remove user from group', error); } } @@ -138,7 +177,7 @@ export class DataService { const updatedGroup = await firstValueFrom(this.groupApiService.addMembers(groupId, { userIds })); this._groups.update(groups => groups.map(g => g.id === groupId ? updatedGroup : g)); } catch (error) { - console.error("Failed to add users to group", error); + console.error('Failed to add users to group', error); } } @@ -146,9 +185,8 @@ export class DataService { try { const updatedUser = await firstValueFrom(this.userApiService.updateCurrentUser(data)); this.currentUser.set(updatedUser); - this._users.update(users => users.map(u => u.id === updatedUser.id ? updatedUser : u)); } catch (error) { - console.error("Failed to update user", error); + console.error('Failed to update user', error); } } @@ -180,7 +218,7 @@ export class DataService { })); this._bets.update(bets => [newBet, ...bets]); } catch (error) { - console.error("Failed to add bet", error); + console.error('Failed to add bet', error); } } } diff --git a/frontend-angular/src/core/tokens/keycloak.token.ts b/frontend-angular/src/core/tokens/keycloak.token.ts new file mode 100644 index 0000000..19529ff --- /dev/null +++ b/frontend-angular/src/core/tokens/keycloak.token.ts @@ -0,0 +1,4 @@ +import { InjectionToken } from '@angular/core'; +import Keycloak from 'keycloak-js'; + +export const KEYCLOAK_INSTANCE = new InjectionToken('KeycloakInstance'); diff --git a/frontend-angular/src/features/bets/create-bet.component.ts b/frontend-angular/src/features/bets/create-bet.component.ts index c7bb8bd..9102562 100644 --- a/frontend-angular/src/features/bets/create-bet.component.ts +++ b/frontend-angular/src/features/bets/create-bet.component.ts @@ -2,7 +2,6 @@ import { Component, ChangeDetectionStrategy, output, inject, signal, computed, O import { CommonModule } from '@angular/common'; import { DataService } from '../../core/services/data.service'; import { BetParticipant, BetRole } from '../../core/models/bet.model'; -import { Group } from '../../core/models/group.model'; import { User } from '../../core/models/user.model'; import { getUserId } from '../../core/utils/type-guards'; @@ -51,6 +50,9 @@ export class CreateBetComponent implements OnInit { }); ngOnInit() { + this.dataService.loadFriends(); + this.dataService.loadGroups(); + const user = this.currentUser(); if (user) { this.participants.set([{ user, role: BetRole.Participant }]); @@ -59,13 +61,13 @@ export class CreateBetComponent implements OnInit { addParticipant(friend: User) { this.participants.update(p => [...p, { user: friend, role: BetRole.Participant }]); - this.groupId.set(undefined); // Reset group if participants are manually changed + this.groupId.set(undefined); this.showFriendSelector.set(false); } removeParticipant(userId: string) { this.participants.update(p => p.filter(participant => getUserId(participant.user) !== userId)); - this.groupId.set(undefined); // Reset group if participants are manually changed + this.groupId.set(undefined); } updateParticipantRole(userId: string, newRole: BetRole) { @@ -94,7 +96,6 @@ export class CreateBetComponent implements OnInit { this.participants.set(groupParticipants); } } else { - // Reset to just the current user if "Nessun gruppo" is selected const user = this.currentUser(); if (user) { this.participants.set([{ user, role: BetRole.Participant }]); diff --git a/frontend-angular/src/features/discover/discover.component.ts b/frontend-angular/src/features/discover/discover.component.ts index 5a99093..add9201 100644 --- a/frontend-angular/src/features/discover/discover.component.ts +++ b/frontend-angular/src/features/discover/discover.component.ts @@ -1,4 +1,4 @@ -import { Component, ChangeDetectionStrategy, inject } from '@angular/core'; +import { Component, ChangeDetectionStrategy, inject, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; import { DataService } from '../../core/services/data.service'; import { BetCardComponent } from '../../shared/components/bet-card/bet-card.component'; @@ -10,7 +10,11 @@ import { BetCardComponent } from '../../shared/components/bet-card/bet-card.comp templateUrl: './discover.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class DiscoverComponent { +export class DiscoverComponent implements OnInit { private dataService = inject(DataService); - publicBets = this.dataService.getPublicBets; -} \ No newline at end of file + readonly publicBets = this.dataService.getPublicBets; + + ngOnInit() { + this.dataService.loadPublicBets(); + } +} diff --git a/frontend-angular/src/features/groups/group-detail/group-detail.component.ts b/frontend-angular/src/features/groups/group-detail/group-detail.component.ts index 0278a9b..b90c302 100644 --- a/frontend-angular/src/features/groups/group-detail/group-detail.component.ts +++ b/frontend-angular/src/features/groups/group-detail/group-detail.component.ts @@ -1,10 +1,8 @@ -import { Component, ChangeDetectionStrategy, input, output, inject, signal, computed, effect } from '@angular/core'; +import { Component, ChangeDetectionStrategy, input, output, inject, signal, computed } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { DataService } from '../../../core/services/data.service'; -import { Bet, BetStatus } from '../../../core/models/bet.model'; -import { Group } from '../../../core/models/group.model'; -import { User } from '../../../core/models/user.model'; -import { BetCardComponent } from '../../../shared/components/bet-card/bet-card.component'; +import { DataService } from '@/src/core/services/data.service'; +import { Bet, BetStatus } from '@/src/core/models/bet.model'; +import { BetCardComponent } from '@/src/shared/components/bet-card/bet-card.component'; @Component({ selector: 'app-group-detail', @@ -22,30 +20,21 @@ export class GroupDetailComponent { private allGroups = this.dataService.userGroups; group = computed(() => this.allGroups().find(g => g.id === this.groupId()) ?? null); - groupBets = signal([]); + readonly groupBets = computed(() => this.dataService.getBetsByGroupId(this.groupId())); isAddingMembers = signal(false); selectedFriendIds = signal<{ [key: string]: boolean }>({}); - constructor() { - effect(() => { - const id = this.groupId(); - if (id) { - this.groupBets.set(this.dataService.getBetsByGroupId(id)); - } - }); - } - sortedMembers = computed(() => { return this.group()?.members.slice().sort((a, b) => b.score - a.score) ?? []; }); activeBets = computed(() => { - return this.groupBets().filter(b => b.status === BetStatus.Active || b.status === BetStatus.AwaitingValidation); + return this.groupBets().filter((b: Bet) => b.status === BetStatus.Active || b.status === BetStatus.AwaitingValidation); }); pastBets = computed(() => { - return this.groupBets().filter(b => b.status === BetStatus.Completed || b.status === BetStatus.Expired); + return this.groupBets().filter((b: Bet) => b.status === BetStatus.Completed || b.status === BetStatus.Expired); }); availableFriends = computed(() => { diff --git a/frontend-angular/src/features/groups/groups.component.ts b/frontend-angular/src/features/groups/groups.component.ts index 7015c48..a968a2a 100644 --- a/frontend-angular/src/features/groups/groups.component.ts +++ b/frontend-angular/src/features/groups/groups.component.ts @@ -1,7 +1,6 @@ -import { Component, ChangeDetectionStrategy, inject, signal, computed } from '@angular/core'; +import { Component, ChangeDetectionStrategy, inject, signal, computed, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; import { DataService } from '../../core/services/data.service'; -import { User } from '../../core/models/user.model'; import { GroupDetailComponent } from './group-detail/group-detail.component'; @Component({ @@ -11,7 +10,7 @@ import { GroupDetailComponent } from './group-detail/group-detail.component'; templateUrl: './groups.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class GroupsComponent { +export class GroupsComponent implements OnInit { private dataService = inject(DataService); groups = this.dataService.userGroups; @@ -27,6 +26,12 @@ export class GroupsComponent { return this.newGroupName().trim().length > 0 && Object.keys(this.selectedFriendIds()).length > 0; }); + ngOnInit() { + this.dataService.loadGroups(); + this.dataService.loadFriends(); + this.dataService.loadUserBets(); + } + selectGroup(groupId: string) { this.selectedGroupId.set(groupId); } diff --git a/frontend-angular/src/features/home/home.component.ts b/frontend-angular/src/features/home/home.component.ts index 511c9b8..4e33320 100644 --- a/frontend-angular/src/features/home/home.component.ts +++ b/frontend-angular/src/features/home/home.component.ts @@ -1,7 +1,6 @@ import { Component, ChangeDetectionStrategy, inject, signal, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; import { DataService } from '../../core/services/data.service'; -import { Bet } from '../../core/models/bet.model'; import { BetsByTimeComponent } from './bets-by-time/bets-by-time.component'; import { BetsByGroupComponent } from './bets-by-group/bets-by-group.component'; @@ -16,12 +15,10 @@ export class HomeComponent implements OnInit { private dataService = inject(DataService); viewMode = signal<'time' | 'group'>('time'); - allBets = signal([]); + readonly allBets = this.dataService.getActiveBets; ngOnInit() { - // FIX: getActiveBets is a computed signal which returns a value when called once. - // The extra parentheses caused an error because it tried to execute an array. - this.allBets.set(this.dataService.getActiveBets()); + this.dataService.loadUserBets(); } setViewMode(mode: 'time' | 'group') { diff --git a/frontend-angular/src/features/profile/profile.component.html b/frontend-angular/src/features/profile/profile.component.html index 1f16605..b886e9e 100644 --- a/frontend-angular/src/features/profile/profile.component.html +++ b/frontend-angular/src/features/profile/profile.component.html @@ -3,7 +3,7 @@

Profilo

- + + + + +
+ <#if realm.rememberMe> + + <#else> + + + + <#if realm.resetPasswordAllowed> + + Password dimenticata? + + +
+ + + + + + + <#if realm.registrationAllowed> +

+ Non hai un account? + + Registrati + +

+ + + + + + + diff --git a/keycloak/themes/scommettiapp/login/register.ftl b/keycloak/themes/scommettiapp/login/register.ftl new file mode 100644 index 0000000..523b040 --- /dev/null +++ b/keycloak/themes/scommettiapp/login/register.ftl @@ -0,0 +1,264 @@ + + + + + + ScommettiApp - Registrati + + + + +
+ + +
+
+ + + + + + + + + + +
+

ScommettiApp

+

Crea il tuo account

+
+ + +
+

Registrazione

+ + + <#if message?has_content> +
+ ${message.summary?no_esc} +
+ + +
+ + +
+
+ + + <#if messagesPerField.existsError('firstName')> +

${messagesPerField.get('firstName')?no_esc}

+ +
+
+ + + <#if messagesPerField.existsError('lastName')> +

${messagesPerField.get('lastName')?no_esc}

+ +
+
+ + +
+ + + <#if messagesPerField.existsError('email')> +

${messagesPerField.get('email')?no_esc}

+ +
+ + + <#if !realm.registrationEmailAsUsername> +
+ + + <#if messagesPerField.existsError('username')> +

${messagesPerField.get('username')?no_esc}

+ +
+ + + +
+ +
+ + +
+ <#if messagesPerField.existsError('password')> +

${messagesPerField.get('password')?no_esc}

+ +
+ + +
+ +
+ + +
+ <#if messagesPerField.existsError('password-confirm')> +

${messagesPerField.get('password-confirm')?no_esc}

+ +
+ + + +
+ + +

+ Hai già un account? + + Accedi + +

+
+
+ + + + diff --git a/keycloak/themes/scommettiapp/login/theme.properties b/keycloak/themes/scommettiapp/login/theme.properties new file mode 100644 index 0000000..5265964 --- /dev/null +++ b/keycloak/themes/scommettiapp/login/theme.properties @@ -0,0 +1,2 @@ +parent=keycloak +import=common/keycloak