Modifiche

This commit is contained in:
Lorenzo Sanesi
2026-04-01 23:49:19 +02:00
parent 8752acd2c4
commit e2fe8b264f
52 changed files with 1351 additions and 400 deletions
+2
View File
@@ -6,3 +6,5 @@
/frontend-angular/.angular/ /frontend-angular/.angular/
/backend-node/node_modules/ /backend-node/node_modules/
/backend-node/.env /backend-node/.env
/frontend-angular/node_modules/
/.claude/
+2 -2
View File
@@ -22,7 +22,7 @@ COPY package*.json ./
FROM base AS dependencies FROM base AS dependencies
# Install ALL dependencies (including devDependencies for build) # Install ALL dependencies (including devDependencies for build)
RUN npm ci RUN npm install
# ======================================== # ========================================
# Stage 3: Development # Stage 3: Development
@@ -50,7 +50,7 @@ CMD ["npm", "start"]
FROM base AS production-dependencies FROM base AS production-dependencies
# Install only production dependencies # Install only production dependencies
RUN npm ci --only=production && \ RUN npm install --only=production && \
npm cache clean --force npm cache clean --force
# ======================================== # ========================================
@@ -5,6 +5,24 @@ class BetsController {
this.betsService = betsService; 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) { async getAllBets(req, res, next) {
try { try {
const bets = await this.betsService.getAllBets(); const bets = await this.betsService.getAllBets();
@@ -7,7 +7,7 @@ class GroupsController {
async getAllGroups(req, res, next) { async getAllGroups(req, res, next) {
try { try {
const groups = await this.groupsService.getAllGroups(); const groups = await this.groupsService.getAllGroups(req.userId);
return ResponseHandler.success(res, groups, 'Groups retrieved successfully'); return ResponseHandler.success(res, groups, 'Groups retrieved successfully');
} catch (error) { } catch (error) {
next(error); next(error);
@@ -52,10 +52,7 @@ class GroupsController {
async addMembers(req, res, next) { async addMembers(req, res, next) {
try { try {
const group = await this.groupsService.addMembersToGroup( const group = await this.groupsService.addMembersToGroup(req.params.id, req.body.userIds);
req.params.id,
req.body.userIds
);
return ResponseHandler.success(res, group, 'Members added successfully'); return ResponseHandler.success(res, group, 'Members added successfully');
} catch (error) { } catch (error) {
next(error); next(error);
@@ -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;
@@ -5,6 +5,15 @@ class UsersController {
this.usersService = usersService; 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) { async updateCurrentUser(req, res, next) {
try { try {
const user = await this.usersService.updateUserProfile(req.userId, req.body); const user = await this.usersService.updateUserProfile(req.userId, req.body);
+6 -17
View File
@@ -18,13 +18,13 @@ function setupContainer() {
const modelsDir = path.join(__dirname, '../models'); const modelsDir = path.join(__dirname, '../models');
fs.readdirSync(modelsDir) fs.readdirSync(modelsDir)
.filter( .filter(
(file) => file =>
file.indexOf('.') !== 0 && file.indexOf('.') !== 0 &&
file.slice(-3) === '.js' && file.slice(-3) === '.js' &&
file !== 'bet.model.js' && file !== 'bet.model.js' &&
file !== 'associations.js' file !== 'associations.js'
) )
.forEach((file) => { .forEach(file => {
require(path.join(modelsDir, file))(sequelize); require(path.join(modelsDir, file))(sequelize);
}); });
@@ -38,27 +38,19 @@ function setupContainer() {
}); });
container.register({ container.register({
usersRepository: asClass( usersRepository: asClass(require('../repositories/postgres/users.repository')).scoped(),
require('../repositories/postgres/users.repository') groupsRepository: asClass(require('../repositories/postgres/groups.repository')).scoped(),
).scoped(),
groupsRepository: asClass(
require('../repositories/postgres/groups.repository')
).scoped(),
friendshipsRepository: asClass( friendshipsRepository: asClass(
require('../repositories/postgres/friendships.repository') require('../repositories/postgres/friendships.repository')
).scoped(), ).scoped(),
friendRequestsRepository: asClass( friendRequestsRepository: asClass(
require('../repositories/postgres/friendRequests.repository') require('../repositories/postgres/friendRequests.repository')
).scoped(), ).scoped(),
betsRepository: asClass( betsRepository: asClass(require('../repositories/mongo/bets.repository')).scoped(),
require('../repositories/mongo/bets.repository')
).scoped(),
}); });
container.register({ container.register({
dataStitchingService: asClass( dataStitchingService: asClass(require('../services/data-stitching.service')).scoped(),
require('../services/data-stitching.service')
).scoped(),
usersService: asClass(require('../services/users.service')).scoped(), usersService: asClass(require('../services/users.service')).scoped(),
groupsService: asClass(require('../services/groups.service')).scoped(), groupsService: asClass(require('../services/groups.service')).scoped(),
friendsService: asClass(require('../services/friends.service')).scoped(), friendsService: asClass(require('../services/friends.service')).scoped(),
@@ -70,9 +62,6 @@ function setupContainer() {
groupsController: asClass(require('../controllers/groups.controller')).scoped(), groupsController: asClass(require('../controllers/groups.controller')).scoped(),
usersController: asClass(require('../controllers/users.controller')).scoped(), usersController: asClass(require('../controllers/users.controller')).scoped(),
friendsController: asClass(require('../controllers/friends.controller')).scoped(), friendsController: asClass(require('../controllers/friends.controller')).scoped(),
initialDataController: asClass(
require('../controllers/initialData.controller')
).scoped(),
}); });
return container; return container;
+2
View File
@@ -11,6 +11,7 @@ DROP TABLE IF EXISTS users CASCADE;
-- users table -- users table
CREATE TABLE users ( CREATE TABLE users (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
"keycloakId" VARCHAR(255) UNIQUE,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
"email" VARCHAR(255) NOT NULL UNIQUE, "email" VARCHAR(255) NOT NULL UNIQUE,
"avatarUrl" VARCHAR(255) NOT NULL, "avatarUrl" VARCHAR(255) NOT NULL,
@@ -63,6 +64,7 @@ CREATE TABLE friend_requests (
-- User <-> Group many-to-many join table -- User <-> Group many-to-many join table
CREATE TABLE "UserGroups" ( CREATE TABLE "UserGroups" (
id SERIAL PRIMARY KEY,
"userId" INTEGER NOT NULL, "userId" INTEGER NOT NULL,
"groupId" INTEGER NOT NULL, "groupId" INTEGER NOT NULL,
CONSTRAINT usergroups_user_fk FOREIGN KEY ("userId") REFERENCES users(id) ON DELETE CASCADE, CONSTRAINT usergroups_user_fk FOREIGN KEY ("userId") REFERENCES users(id) ON DELETE CASCADE,
+67
View File
@@ -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);
}
});
};
};
+42 -17
View File
@@ -1,21 +1,46 @@
module.exports = (sequelize) => { // eslint-disable-next-line import/no-import-module-exports
const { User, Group, FriendRequest, Friendship } = sequelize.models; import { DataTypes } from 'sequelize';
// User <-> Group: Many-to-Many module.exports = sequelize => {
// A User can be in many Groups, and a Group can have many Users (members). const { User, Group, FriendRequest, Friendship } = sequelize.models;
const UserGroup = sequelize.define('UserGroup', {}, { timestamps: false });
User.belongsToMany(Group, { through: UserGroup, foreignKey: 'userId' });
Group.belongsToMany(User, { through: UserGroup, as: 'members', foreignKey: 'groupId' });
// Friendship is a direct link between two users. // User <-> Group: Many-to-Many
// user1Id will always be less than user2Id to ensure uniqueness. // Il modello dichiara esplicitamente la colonna id per evitare che sync() la cerchi
Friendship.belongsTo(User, { as: 'user1', foreignKey: 'user1Id' }); // sulla tabella UserGroups senza trovarla (causando errori su RETURNING "id").
Friendship.belongsTo(User, { as: 'user2', foreignKey: 'user2Id' }); 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 // Friendship is a direct link between two users.
// A User can send many friend requests and receive many friend requests. // user1Id will always be less than user2Id to ensure uniqueness.
User.hasMany(FriendRequest, { as: 'sentRequests', foreignKey: 'fromUserId', onDelete: 'CASCADE' }); Friendship.belongsTo(User, { as: 'user1', foreignKey: 'user1Id' });
User.hasMany(FriendRequest, { as: 'receivedRequests', foreignKey: 'toUserId', onDelete: 'CASCADE' }); Friendship.belongsTo(User, { as: 'user2', foreignKey: 'user2Id' });
FriendRequest.belongsTo(User, { as: 'fromUser', foreignKey: 'fromUserId' });
FriendRequest.belongsTo(User, { as: 'toUser', foreignKey: 'toUserId' }); // 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' });
}; };
+28 -15
View File
@@ -1,21 +1,34 @@
const { DataTypes } = require('sequelize'); const { DataTypes } = require('sequelize');
module.exports = (sequelize) => { module.exports = sequelize => {
const FriendRequest = sequelize.define('FriendRequest', { const FriendRequest = sequelize.define(
id: { 'FriendRequest',
type: DataTypes.INTEGER, {
autoIncrement: true, id: {
primaryKey: true 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, // Uses default `createdAt` and `updatedAt` timestamps from Sequelize
defaultValue: 'PENDING', tableName: 'friend_requests',
allowNull: false
} }
// Foreign keys 'fromUserId' and 'toUserId' are added via associations );
}, {
// Uses default `createdAt` and `updatedAt` timestamps from Sequelize FriendRequest.prototype.toJSON = function () {
tableName: 'friend_requests' 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; return FriendRequest;
}; };
+24 -13
View File
@@ -1,19 +1,30 @@
const { DataTypes } = require('sequelize'); const { DataTypes } = require('sequelize');
module.exports = (sequelize) => { module.exports = sequelize => {
const Group = sequelize.define('Group', { const Group = sequelize.define(
id: { 'Group',
type: DataTypes.INTEGER, {
autoIncrement: true, id: {
primaryKey: true type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true,
},
name: {
type: DataTypes.STRING,
allowNull: false,
},
}, },
name: { {
type: DataTypes.STRING, timestamps: false,
allowNull: 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; return Group;
}; };
+46 -30
View File
@@ -1,35 +1,51 @@
const { DataTypes } = require('sequelize'); const { DataTypes } = require('sequelize');
module.exports = (sequelize) => { module.exports = sequelize => {
const User = sequelize.define('User', { const User = sequelize.define(
id: { 'User',
type: DataTypes.INTEGER, {
autoIncrement: true, id: {
primaryKey: true 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, timestamps: false,
allowNull: false tableName: 'users',
}, }
email: { );
type: DataTypes.STRING,
allowNull: false, User.prototype.toJSON = function () {
unique: true, const values = { ...this.get() };
validate: { if (values.id !== undefined) values.id = String(values.id);
isEmail: true return values;
} };
},
avatarUrl: {
type: DataTypes.STRING,
allowNull: false
},
score: {
type: DataTypes.INTEGER,
defaultValue: 0
},
}, {
timestamps: false,
tableName: 'users'
});
return User; return User;
}; };
+205
View File
@@ -13,6 +13,8 @@
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.19.2", "express": "^4.19.2",
"joi": "^17.12.1", "joi": "^17.12.1",
"jsonwebtoken": "^9.0.2",
"jwks-rsa": "^3.1.0",
"mongoose": "^8.4.0", "mongoose": "^8.4.0",
"pg": "^8.11.5", "pg": "^8.11.5",
"pg-hstore": "^2.3.4", "pg-hstore": "^2.3.4",
@@ -294,6 +296,16 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/ms": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
@@ -653,6 +665,12 @@
"node": ">=16.20.1" "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": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -1003,6 +1021,15 @@
"node": ">= 0.4" "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": { "node_modules/ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -2640,6 +2667,15 @@
"@sideway/pinpoint": "^2.0.0" "@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": { "node_modules/js-yaml": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
@@ -2687,6 +2723,94 @@
"json5": "lib/cli.js" "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": { "node_modules/kareem": {
"version": "2.6.3", "version": "2.6.3",
"resolved": "https://registry.npmjs.org/kareem/-/kareem-2.6.3.tgz", "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.6.3.tgz",
@@ -2720,6 +2844,11 @@
"node": ">= 0.8.0" "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": { "node_modules/locate-path": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -2742,6 +2871,48 @@
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT" "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": { "node_modules/lodash.merge": {
"version": "4.6.2", "version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -2749,6 +2920,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/lower-case": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
@@ -2758,6 +2935,28 @@
"tslib": "^2.0.3" "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": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -4763,6 +4962,12 @@
"node": ">=0.4" "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": { "node_modules/yocto-queue": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+2
View File
@@ -30,6 +30,8 @@
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.19.2", "express": "^4.19.2",
"joi": "^17.12.1", "joi": "^17.12.1",
"jsonwebtoken": "^9.0.2",
"jwks-rsa": "^3.1.0",
"mongoose": "^8.4.0", "mongoose": "^8.4.0",
"pg": "^8.11.5", "pg": "^8.11.5",
"pg-hstore": "^2.3.4", "pg-hstore": "^2.3.4",
@@ -1,5 +1,5 @@
const { NotFoundError, DatabaseError } = require('../../shared/errors');
const { Op } = require('sequelize'); const { Op } = require('sequelize');
const { NotFoundError, DatabaseError } = require('../../shared/errors');
class FriendRequestsRepository { class FriendRequestsRepository {
constructor({ sequelize }) { constructor({ sequelize }) {
@@ -30,7 +30,7 @@ class FriendRequestsRepository {
try { try {
return await this.FriendRequest.findAll({ return await this.FriendRequest.findAll({
where: { where: {
toUserId: userId, [Op.or]: [{ toUserId: userId }, { fromUserId: userId }],
status: 'PENDING', status: 'PENDING',
}, },
include: [ include: [
@@ -39,10 +39,7 @@ class FriendRequestsRepository {
], ],
}); });
} catch (error) { } catch (error) {
throw new DatabaseError( throw new DatabaseError(`Failed to fetch pending requests for user ${userId}`, error);
`Failed to fetch pending requests for user ${userId}`,
error
);
} }
} }
@@ -64,11 +61,12 @@ class FriendRequestsRepository {
async create(fromUserId, toUserId) { async create(fromUserId, toUserId) {
try { try {
return await this.FriendRequest.create({ const created = await this.FriendRequest.create({
fromUserId, fromUserId,
toUserId, toUserId,
status: 'PENDING', status: 'PENDING',
}); });
return await this.findById(created.id);
} catch (error) { } catch (error) {
throw new DatabaseError('Failed to create friend request', error); throw new DatabaseError('Failed to create friend request', error);
} }
@@ -1,3 +1,4 @@
const { Op } = require('sequelize');
const { NotFoundError, DatabaseError } = require('../../shared/errors'); const { NotFoundError, DatabaseError } = require('../../shared/errors');
class GroupsRepository { 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) { async findById(id, includeMembers = false) {
try { try {
const options = {}; const options = {};
+22 -23
View File
@@ -1,31 +1,30 @@
const express = require('express'); const express = require('express');
// Il router è ora una factory function che riceve l'istanza di sequelize // Il router è ora una factory function che riceve l'istanza di sequelize
module.exports = (sequelize) => { module.exports = sequelize => {
const router = express.Router(); const router = express.Router();
// --- Middleware per simulare un utente loggato --- // --- Middleware per simulare un utente loggato ---
const { User } = sequelize.models; const { User } = sequelize.models;
const MOCK_USER_NAME = 'Mario Rossi'; const MOCK_USER_NAME = 'Mario Rossi';
let MOCK_USER_ID = null; let MOCK_USER_ID = null;
router.use(async (req, res, next) => { router.use(async (req, res, next) => {
if (!MOCK_USER_ID) { if (!MOCK_USER_ID) {
const user = await User.findOne({ where: { name: MOCK_USER_NAME } }); const user = await User.findOne({ where: { name: MOCK_USER_NAME } });
if (user) { if (user) {
MOCK_USER_ID = user.id; MOCK_USER_ID = user.id;
} }
} }
req.userId = MOCK_USER_ID; req.userId = MOCK_USER_ID;
next(); next();
}); });
// --- Monta i router da file separati --- // --- Monta i router da file separati ---
router.use(require('./initial-data.routes')(sequelize)); router.use('/bets', require('./bets.routes')(sequelize));
router.use('/bets', require('./bets.routes')(sequelize)); router.use('/groups', require('./groups.routes')(sequelize));
router.use('/groups', require('./groups.routes')(sequelize)); router.use('/friend-requests', require('./friends.routes')(sequelize));
router.use('/friend-requests', require('./friends.routes')(sequelize)); router.use('/users', require('./users.routes')(sequelize));
router.use('/users', require('./users.routes')(sequelize));
return router; return router;
}; };
@@ -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;
};
+5 -4
View File
@@ -1,14 +1,13 @@
const express = require('express'); const express = require('express');
const { validate } = require('../../middleware/validation'); const { validate } = require('../../middleware/validation');
const { const { createBetSchema, updateBetSchema } = require('../../shared/validators/schemas/bet.schema');
createBetSchema,
updateBetSchema,
} = require('../../shared/validators/schemas/bet.schema');
module.exports = ({ betsController }) => { module.exports = ({ betsController }) => {
const router = express.Router(); const router = express.Router();
const controller = { 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), getAllBets: (req, res, next) => betsController.getAllBets(req, res, next),
getBetById: (req, res, next) => betsController.getBetById(req, res, next), getBetById: (req, res, next) => betsController.getBetById(req, res, next),
createBet: (req, res, next) => betsController.createBet(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), 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('/', controller.getAllBets);
router.get('/:id', controller.getBetById); router.get('/:id', controller.getBetById);
router.post('/', validate(createBetSchema), controller.createBet); router.post('/', validate(createBetSchema), controller.createBet);
+1 -3
View File
@@ -1,6 +1,6 @@
const express = require('express'); const express = require('express');
module.exports = (container) => { module.exports = container => {
const router = express.Router(); const router = express.Router();
const controllers = { const controllers = {
@@ -8,14 +8,12 @@ module.exports = (container) => {
betsController: container.resolve('betsController'), betsController: container.resolve('betsController'),
usersController: container.resolve('usersController'), usersController: container.resolve('usersController'),
friendsController: container.resolve('friendsController'), friendsController: container.resolve('friendsController'),
initialDataController: container.resolve('initialDataController'),
}; };
router.use('/groups', require('./groups.routes')(controllers)); router.use('/groups', require('./groups.routes')(controllers));
router.use('/bets', require('./bets.routes')(controllers)); router.use('/bets', require('./bets.routes')(controllers));
router.use('/users', require('./users.routes')(controllers)); router.use('/users', require('./users.routes')(controllers));
router.use('/friend-requests', require('./friends.routes')(controllers)); router.use('/friend-requests', require('./friends.routes')(controllers));
router.use('/initial-data', require('./initialData.routes')(controllers));
return router; return router;
}; };
@@ -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;
};
+2
View File
@@ -6,9 +6,11 @@ module.exports = ({ usersController }) => {
const router = express.Router(); const router = express.Router();
const controller = { const controller = {
getCurrentUser: (req, res, next) => usersController.getCurrentUser(req, res, next),
updateCurrentUser: (req, res, next) => usersController.updateCurrentUser(req, res, next), updateCurrentUser: (req, res, next) => usersController.updateCurrentUser(req, res, next),
}; };
router.get('/current', controller.getCurrentUser);
router.put('/current', validate(updateUserSchema), controller.updateCurrentUser); router.put('/current', validate(updateUserSchema), controller.updateCurrentUser);
return router; return router;
+7 -7
View File
@@ -4,8 +4,7 @@ const cors = require('cors');
const config = require('./config'); const config = require('./config');
const setupContainer = require('./di/container'); const setupContainer = require('./di/container');
const errorHandler = require('./middleware/errorHandler'); const errorHandler = require('./middleware/errorHandler');
const mockAuthMiddleware = require('./middleware/auth.mock'); const keycloakAuthMiddleware = require('./middleware/auth.keycloak');
const { seedDatabase } = require('./seed');
const legacyApiRoutes = require('./routes/api'); const legacyApiRoutes = require('./routes/api');
@@ -21,20 +20,22 @@ const sequelize = container.resolve('sequelize');
mongoose mongoose
.connect(config.database.mongo.uri, config.database.mongo.options) .connect(config.database.mongo.uri, config.database.mongo.options)
.then(() => console.log('Connected to MongoDB')) .then(() => console.log('Connected to MongoDB'))
.catch((err) => { .catch(err => {
console.error('MongoDB connection error:', err); console.error('MongoDB connection error:', err);
process.exit(1); process.exit(1);
}); });
app.use('/api', mockAuthMiddleware(sequelize)); app.use('/api', keycloakAuthMiddleware(sequelize));
app.use('/api/legacy', legacyApiRoutes(sequelize)); app.use('/api/legacy', legacyApiRoutes(sequelize));
const v1Routes = require('./routes/v1'); const v1Routes = require('./routes/v1');
app.use('/api/v1', v1Routes(container)); app.use('/api/v1', v1Routes(container));
app.use('/api', v1Routes(container)); app.use('/api', v1Routes(container));
const healthRoutes = require('./routes/health.routes'); const healthRoutes = require('./routes/health.routes');
app.use('/', healthRoutes({ sequelize, mongoose })); app.use('/', healthRoutes({ sequelize, mongoose }));
app.use(errorHandler); app.use(errorHandler);
@@ -43,11 +44,10 @@ sequelize
.authenticate() .authenticate()
.then(() => { .then(() => {
console.log('Connected to PostgreSQL'); console.log('Connected to PostgreSQL');
return sequelize.sync({ force: true }); return sequelize.sync({ alter: true });
}) })
.then(() => { .then(() => {
console.log('PostgreSQL models synchronized'); console.log('PostgreSQL models synchronized');
return seedDatabase(sequelize);
}) })
.then(() => { .then(() => {
app.listen(config.port, () => { app.listen(config.port, () => {
@@ -58,7 +58,7 @@ sequelize
console.log(`Health check available at /health`); console.log(`Health check available at /health`);
}); });
}) })
.catch((err) => { .catch(err => {
console.error('PostgreSQL connection or sync error:', err); console.error('PostgreSQL connection or sync error:', err);
process.exit(1); process.exit(1);
}); });
+8 -19
View File
@@ -1,13 +1,8 @@
const { ValidationError, ConflictError } = require('../shared/errors');
const { Op } = require('sequelize'); const { Op } = require('sequelize');
const { ValidationError, ConflictError } = require('../shared/errors');
class FriendsService { class FriendsService {
constructor({ constructor({ friendshipsRepository, friendRequestsRepository, usersRepository, sequelize }) {
friendshipsRepository,
friendRequestsRepository,
usersRepository,
sequelize,
}) {
this.friendshipsRepository = friendshipsRepository; this.friendshipsRepository = friendshipsRepository;
this.friendRequestsRepository = friendRequestsRepository; this.friendRequestsRepository = friendRequestsRepository;
this.usersRepository = usersRepository; this.usersRepository = usersRepository;
@@ -33,9 +28,7 @@ class FriendsService {
}); });
if (!toUser) { if (!toUser) {
throw new ValidationError( throw new ValidationError('User not found with provided name or email');
'User not found with provided name or email'
);
} }
} }
@@ -43,18 +36,12 @@ class FriendsService {
throw new ValidationError('Cannot send friend request to yourself'); throw new ValidationError('Cannot send friend request to yourself');
} }
const friendshipExists = await this.friendshipsRepository.exists( const friendshipExists = await this.friendshipsRepository.exists(fromUserId, toUser.id);
fromUserId,
toUser.id
);
if (friendshipExists) { if (friendshipExists) {
throw new ConflictError('You are already friends with this user'); throw new ConflictError('You are already friends with this user');
} }
const existingRequest = await this.friendRequestsRepository.findExisting( const existingRequest = await this.friendRequestsRepository.findExisting(fromUserId, toUser.id);
fromUserId,
toUser.id
);
if (existingRequest) { if (existingRequest) {
throw new ConflictError('Friend request already exists'); throw new ConflictError('Friend request already exists');
} }
@@ -73,6 +60,8 @@ class FriendsService {
throw new ValidationError('This request has already been processed'); throw new ValidationError('This request has already been processed');
} }
const newFriend = request.fromUser;
const t = await this.sequelize.transaction(); const t = await this.sequelize.transaction();
try { try {
await this.friendRequestsRepository.updateStatus(requestId, 'ACCEPTED'); await this.friendRequestsRepository.updateStatus(requestId, 'ACCEPTED');
@@ -80,7 +69,7 @@ class FriendsService {
await this.friendRequestsRepository.delete(requestId); await this.friendRequestsRepository.delete(requestId);
await t.commit(); await t.commit();
return { message: 'Friend request accepted successfully' }; return { message: 'Friend request accepted successfully', newFriend };
} catch (error) { } catch (error) {
await t.rollback(); await t.rollback();
throw error; throw error;
+2 -10
View File
@@ -6,16 +6,8 @@ class GroupsService {
this.usersRepository = usersRepository; this.usersRepository = usersRepository;
} }
async getAllGroups() { async getAllGroups(userId) {
return await this.groupsRepository.findAll({ return await this.groupsRepository.findByUserId(userId);
include: [
{
model: this.usersRepository.User,
as: 'members',
through: { attributes: [] },
},
],
});
} }
async getGroupById(groupId) { async getGroupById(groupId) {
+57 -1
View File
@@ -42,6 +42,57 @@ services:
timeout: 5s timeout: 5s
retries: 5 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 Node.js
backend: backend:
build: build:
@@ -53,6 +104,7 @@ services:
PORT: 3000 PORT: 3000
POSTGRES_URI: postgres://admin:admin@postgres:5432/amici_scommesse POSTGRES_URI: postgres://admin:admin@postgres:5432/amici_scommesse
MONGO_URI: mongodb://mongodb:27017/amici-scommesse MONGO_URI: mongodb://mongodb:27017/amici-scommesse
KEYCLOAK_JWKS_URI: http://keycloak:8080/realms/scommettiapp/protocol/openid-connect/certs
ports: ports:
- "3000:3000" - "3000:3000"
volumes: volumes:
@@ -63,6 +115,8 @@ services:
condition: service_healthy condition: service_healthy
mongodb: mongodb:
condition: service_healthy condition: service_healthy
keycloak:
condition: service_healthy
networks: networks:
- scommetti-network - scommetti-network
command: npm start command: npm start
@@ -84,9 +138,9 @@ services:
- /app/.angular - /app/.angular
depends_on: depends_on:
- backend - backend
- keycloak
networks: networks:
- scommetti-network - scommetti-network
command: npm run dev
restart: unless-stopped restart: unless-stopped
volumes: volumes:
@@ -94,6 +148,8 @@ volumes:
driver: local driver: local
mongodb_data: mongodb_data:
driver: local driver: local
keycloak_db_data:
driver: local
networks: networks:
scommetti-network: scommetti-network:
+1 -1
View File
@@ -8,7 +8,7 @@ WORKDIR /app
COPY package*.json ./ COPY package*.json ./
# Install dependencies # Install dependencies
RUN npm ci RUN npm install
# Copy application code # Copy application code
COPY . . COPY . .
+1 -1
View File
@@ -34,7 +34,7 @@
"serve": { "serve": {
"builder": "@angular/build:dev-server", "builder": "@angular/build:dev-server",
"options": { "options": {
"port": 3000 "port": 4200
}, },
"configurations": { "configurations": {
"production": { "production": {
+29 -8
View File
@@ -1,15 +1,36 @@
import '@angular/compiler'; import '@angular/compiler';
import { bootstrapApplication } from '@angular/platform-browser'; import { bootstrapApplication } from '@angular/platform-browser';
import { provideZonelessChangeDetection } from '@angular/core'; 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 { AppComponent } from './src/app.component';
import { KEYCLOAK_INSTANCE } from './src/core/tokens/keycloak.token';
import { authInterceptor } from './src/core/interceptors/auth.interceptor';
bootstrapApplication(AppComponent, { const keycloak = new Keycloak({
providers: [ url: 'http://localhost:8080',
provideZonelessChangeDetection(), realm: 'scommettiapp',
provideHttpClient(withFetch()), clientId: 'scommetti-frontend',
], });
}).catch(err => console.error(err));
// AI Studio always uses an `index.tsx` file for all project types. 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);
});
+26
View File
@@ -15,6 +15,7 @@
"@angular/compiler-cli": "^20.3.0", "@angular/compiler-cli": "^20.3.0",
"@angular/core": "^20.3.0", "@angular/core": "^20.3.0",
"@angular/platform-browser": "^20.3.0", "@angular/platform-browser": "^20.3.0",
"keycloak-js": "^24.0.0",
"rxjs": "^7.8.2", "rxjs": "^7.8.2",
"tailwindcss": "latest" "tailwindcss": "latest"
}, },
@@ -4931,6 +4932,12 @@
"@pkgjs/parseargs": "^0.11.0" "@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": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -4991,6 +4998,25 @@
], ],
"license": "MIT" "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": { "node_modules/listr2": {
"version": "9.0.1", "version": "9.0.1",
"resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.1.tgz", "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.1.tgz",
+1
View File
@@ -17,6 +17,7 @@
"@angular/build": "^20.3.0", "@angular/build": "^20.3.0",
"@angular/cli": "^20.3.0", "@angular/cli": "^20.3.0",
"@angular/compiler-cli": "^20.3.0", "@angular/compiler-cli": "^20.3.0",
"keycloak-js": "^24.0.0",
"tailwindcss": "latest" "tailwindcss": "latest"
}, },
"devDependencies": { "devDependencies": {
+8 -1
View File
@@ -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 { CommonModule } from '@angular/common';
import { HomeComponent } from './features/home/home.component'; import { HomeComponent } from './features/home/home.component';
import { DiscoverComponent } from './features/discover/discover.component'; import { DiscoverComponent } from './features/discover/discover.component';
import { GroupsComponent } from './features/groups/groups.component'; import { GroupsComponent } from './features/groups/groups.component';
import { ProfileComponent } from './features/profile/profile.component'; import { ProfileComponent } from './features/profile/profile.component';
import { CreateBetComponent } from './features/bets/create-bet.component'; import { CreateBetComponent } from './features/bets/create-bet.component';
import { DataService } from './core/services/data.service';
type View = 'home' | 'discover' | 'groups' | 'profile' | 'create-bet'; type View = 'home' | 'discover' | 'groups' | 'profile' | 'create-bet';
@@ -23,9 +24,15 @@ type View = 'home' | 'discover' | 'groups' | 'profile' | 'create-bet';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class AppComponent { export class AppComponent {
private dataService = inject(DataService);
currentView = signal<View>('home'); currentView = signal<View>('home');
private previousView = signal<View>('home'); private previousView = signal<View>('home');
constructor() {
this.dataService.loadCurrentUser();
}
setView(view: View) { setView(view: View) {
if (view === 'create-bet') { if (view === 'create-bet') {
this.previousView.set(this.currentView()); this.previousView.set(this.currentView());
@@ -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);
})
);
};
@@ -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<T = any> { export interface SuccessResponse<T = any> {
success: boolean; success: boolean;
message?: string; message?: string;
@@ -21,16 +16,3 @@ export interface ErrorResponse {
errors?: ErrorDetail[]; errors?: ErrorDetail[];
timestamp: string; 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[];
}
@@ -21,6 +21,18 @@ export class BetApiService {
private http = inject(HttpClient); private http = inject(HttpClient);
private readonly apiUrl = 'http://localhost:3000/api/v1/bets'; private readonly apiUrl = 'http://localhost:3000/api/v1/bets';
getUserBets(): Observable<Bet[]> {
return this.http.get<SuccessResponse<Bet[]>>(`${this.apiUrl}/user`).pipe(
map(res => res.data.map(mapBet))
);
}
getPublicBets(): Observable<Bet[]> {
return this.http.get<SuccessResponse<Bet[]>>(`${this.apiUrl}/public`).pipe(
map(res => res.data.map(mapBet))
);
}
getBets(): Observable<Bet[]> { getBets(): Observable<Bet[]> {
return this.http.get<SuccessResponse<Bet[]>>(this.apiUrl).pipe( return this.http.get<SuccessResponse<Bet[]>>(this.apiUrl).pipe(
map(res => res.data.map(mapBet)) map(res => res.data.map(mapBet))
@@ -13,6 +13,18 @@ export class FriendRequestApiService {
private http = inject(HttpClient); private http = inject(HttpClient);
private readonly apiUrl = 'http://localhost:3000/api/v1/friend-requests'; private readonly apiUrl = 'http://localhost:3000/api/v1/friend-requests';
getFriends(): Observable<User[]> {
return this.http.get<SuccessResponse<User[]>>(this.apiUrl).pipe(
map(res => res.data)
);
}
getPendingRequests(): Observable<FriendRequest[]> {
return this.http.get<SuccessResponse<FriendRequest[]>>(`${this.apiUrl}/pending`).pipe(
map(res => res.data)
);
}
sendRequest(request: SendFriendRequestRequest): Observable<FriendRequest> { sendRequest(request: SendFriendRequestRequest): Observable<FriendRequest> {
return this.http.post<SuccessResponse<FriendRequest>>(this.apiUrl, request).pipe( return this.http.post<SuccessResponse<FriendRequest>>(this.apiUrl, request).pipe(
map(res => res.data) map(res => res.data)
@@ -2,24 +2,8 @@ import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { User, UpdateUserRequest } from '../../models/user.model'; 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 { 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({ @Injectable({
providedIn: 'root' providedIn: 'root'
@@ -28,9 +12,9 @@ export class UserApiService {
private http = inject(HttpClient); private http = inject(HttpClient);
private readonly apiUrl = 'http://localhost:3000/api/v1'; private readonly apiUrl = 'http://localhost:3000/api/v1';
getInitialData(): Observable<InitialData> { getCurrentUser(): Observable<User> {
return this.http.get<SuccessResponse<InitialData>>(`${this.apiUrl}/initial-data`).pipe( return this.http.get<SuccessResponse<User>>(`${this.apiUrl}/users/current`).pipe(
map(res => mapInitialData(res.data)) map(res => res.data)
); );
} }
@@ -39,6 +23,4 @@ export class UserApiService {
map(res => res.data) map(res => res.data)
); );
} }
// Potential future: getProfile(id), etc.
} }
@@ -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<string> {
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 });
}
}
@@ -18,7 +18,6 @@ export class DataService {
private userApiService = inject(UserApiService); private userApiService = inject(UserApiService);
private friendRequestApiService = inject(FriendRequestApiService); private friendRequestApiService = inject(FriendRequestApiService);
private readonly _users = signal<User[]>([]);
private readonly _bets = signal<Bet[]>([]); private readonly _bets = signal<Bet[]>([]);
private readonly _groups = signal<Group[]>([]); private readonly _groups = signal<Group[]>([]);
private readonly _friends = signal<User[]>([]); private readonly _friends = signal<User[]>([]);
@@ -47,26 +46,64 @@ export class DataService {
readonly getPublicBets = computed(() => this._publicBets()); readonly getPublicBets = computed(() => this._publicBets());
constructor() { // --- Load methods: chiamati da ogni componente al momento della visualizzazione ---
this._initializeData();
async loadCurrentUser(): Promise<void> {
try {
const user = await firstValueFrom(this.userApiService.getCurrentUser());
this.currentUser.set(user);
} catch (error) {
console.error('Failed to load current user', error);
}
} }
private _initializeData() { async loadUserBets(): Promise<void> {
this.userApiService.getInitialData().subscribe({ try {
next: data => { const bets = await firstValueFrom(this.betApiService.getUserBets());
this.currentUser.set(data.currentUser); this._bets.set(bets);
this._users.set(data.users); } catch (error) {
this._friends.set(data.friends); console.error('Failed to load user bets', error);
// 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 loadPublicBets(): Promise<void> {
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<void> {
try {
const friends = await firstValueFrom(this.friendRequestApiService.getFriends());
this._friends.set(friends);
} catch (error) {
console.error('Failed to load friends', error);
}
}
async loadFriendRequests(): Promise<void> {
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<void> {
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 { getGroupById(id: string): Group | undefined {
return this._groups().find(g => g.id === id); return this._groups().find(g => g.id === id);
} }
@@ -75,6 +112,8 @@ export class DataService {
return this._bets().filter(b => b.groupId === groupId); return this._bets().filter(b => b.groupId === groupId);
} }
// --- Mutations ---
async sendFriendRequest(identifier: string): Promise<{ success: boolean, message: string }> { async sendFriendRequest(identifier: string): Promise<{ success: boolean, message: string }> {
try { try {
const newRequest = await firstValueFrom(this.friendRequestApiService.sendRequest({ identifier })); const newRequest = await firstValueFrom(this.friendRequestApiService.sendRequest({ identifier }));
@@ -90,7 +129,7 @@ export class DataService {
await firstValueFrom(this.friendRequestApiService.cancelRequest(requestId)); await firstValueFrom(this.friendRequestApiService.cancelRequest(requestId));
this._friendRequests.update(reqs => reqs.filter(r => r.id !== requestId)); this._friendRequests.update(reqs => reqs.filter(r => r.id !== requestId));
} catch (error) { } 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._friends.update(f => [...f, newFriend]);
this._friendRequests.update(reqs => reqs.filter(r => r.id !== request.id)); this._friendRequests.update(reqs => reqs.filter(r => r.id !== request.id));
} catch (error) { } 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)); await firstValueFrom(this.friendRequestApiService.declineRequest(requestId));
this._friendRequests.update(reqs => reqs.filter(r => r.id !== requestId)); this._friendRequests.update(reqs => reqs.filter(r => r.id !== requestId));
} catch (error) { } 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 })); const newGroup = await firstValueFrom(this.groupApiService.createGroup({ name, members: memberIds }));
this._groups.update(groups => [...groups, newGroup]); this._groups.update(groups => [...groups, newGroup]);
} catch (error) { } 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)); const updatedGroup = await firstValueFrom(this.groupApiService.removeMember(groupId, userId));
this._groups.update(groups => groups.map(g => g.id === groupId ? updatedGroup : g)); this._groups.update(groups => groups.map(g => g.id === groupId ? updatedGroup : g));
} catch (error) { } 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 })); const updatedGroup = await firstValueFrom(this.groupApiService.addMembers(groupId, { userIds }));
this._groups.update(groups => groups.map(g => g.id === groupId ? updatedGroup : g)); this._groups.update(groups => groups.map(g => g.id === groupId ? updatedGroup : g));
} catch (error) { } 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 { try {
const updatedUser = await firstValueFrom(this.userApiService.updateCurrentUser(data)); const updatedUser = await firstValueFrom(this.userApiService.updateCurrentUser(data));
this.currentUser.set(updatedUser); this.currentUser.set(updatedUser);
this._users.update(users => users.map(u => u.id === updatedUser.id ? updatedUser : u));
} catch (error) { } 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]); this._bets.update(bets => [newBet, ...bets]);
} catch (error) { } catch (error) {
console.error("Failed to add bet", error); console.error('Failed to add bet', error);
} }
} }
} }
@@ -0,0 +1,4 @@
import { InjectionToken } from '@angular/core';
import Keycloak from 'keycloak-js';
export const KEYCLOAK_INSTANCE = new InjectionToken<Keycloak>('KeycloakInstance');
@@ -2,7 +2,6 @@ import { Component, ChangeDetectionStrategy, output, inject, signal, computed, O
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { DataService } from '../../core/services/data.service'; import { DataService } from '../../core/services/data.service';
import { BetParticipant, BetRole } from '../../core/models/bet.model'; import { BetParticipant, BetRole } from '../../core/models/bet.model';
import { Group } from '../../core/models/group.model';
import { User } from '../../core/models/user.model'; import { User } from '../../core/models/user.model';
import { getUserId } from '../../core/utils/type-guards'; import { getUserId } from '../../core/utils/type-guards';
@@ -51,6 +50,9 @@ export class CreateBetComponent implements OnInit {
}); });
ngOnInit() { ngOnInit() {
this.dataService.loadFriends();
this.dataService.loadGroups();
const user = this.currentUser(); const user = this.currentUser();
if (user) { if (user) {
this.participants.set([{ user, role: BetRole.Participant }]); this.participants.set([{ user, role: BetRole.Participant }]);
@@ -59,13 +61,13 @@ export class CreateBetComponent implements OnInit {
addParticipant(friend: User) { addParticipant(friend: User) {
this.participants.update(p => [...p, { user: friend, role: BetRole.Participant }]); 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); this.showFriendSelector.set(false);
} }
removeParticipant(userId: string) { removeParticipant(userId: string) {
this.participants.update(p => p.filter(participant => getUserId(participant.user) !== userId)); 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) { updateParticipantRole(userId: string, newRole: BetRole) {
@@ -94,7 +96,6 @@ export class CreateBetComponent implements OnInit {
this.participants.set(groupParticipants); this.participants.set(groupParticipants);
} }
} else { } else {
// Reset to just the current user if "Nessun gruppo" is selected
const user = this.currentUser(); const user = this.currentUser();
if (user) { if (user) {
this.participants.set([{ user, role: BetRole.Participant }]); this.participants.set([{ user, role: BetRole.Participant }]);
@@ -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 { CommonModule } from '@angular/common';
import { DataService } from '../../core/services/data.service'; import { DataService } from '../../core/services/data.service';
import { BetCardComponent } from '../../shared/components/bet-card/bet-card.component'; 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', templateUrl: './discover.component.html',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class DiscoverComponent { export class DiscoverComponent implements OnInit {
private dataService = inject(DataService); private dataService = inject(DataService);
publicBets = this.dataService.getPublicBets; readonly publicBets = this.dataService.getPublicBets;
ngOnInit() {
this.dataService.loadPublicBets();
}
} }
@@ -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 { CommonModule } from '@angular/common';
import { DataService } from '../../../core/services/data.service'; import { DataService } from '@/src/core/services/data.service';
import { Bet, BetStatus } from '../../../core/models/bet.model'; import { Bet, BetStatus } from '@/src/core/models/bet.model';
import { Group } from '../../../core/models/group.model'; import { BetCardComponent } from '@/src/shared/components/bet-card/bet-card.component';
import { User } from '../../../core/models/user.model';
import { BetCardComponent } from '../../../shared/components/bet-card/bet-card.component';
@Component({ @Component({
selector: 'app-group-detail', selector: 'app-group-detail',
@@ -22,30 +20,21 @@ export class GroupDetailComponent {
private allGroups = this.dataService.userGroups; private allGroups = this.dataService.userGroups;
group = computed(() => this.allGroups().find(g => g.id === this.groupId()) ?? null); group = computed(() => this.allGroups().find(g => g.id === this.groupId()) ?? null);
groupBets = signal<Bet[]>([]); readonly groupBets = computed(() => this.dataService.getBetsByGroupId(this.groupId()));
isAddingMembers = signal(false); isAddingMembers = signal(false);
selectedFriendIds = signal<{ [key: string]: boolean }>({}); selectedFriendIds = signal<{ [key: string]: boolean }>({});
constructor() {
effect(() => {
const id = this.groupId();
if (id) {
this.groupBets.set(this.dataService.getBetsByGroupId(id));
}
});
}
sortedMembers = computed(() => { sortedMembers = computed(() => {
return this.group()?.members.slice().sort((a, b) => b.score - a.score) ?? []; return this.group()?.members.slice().sort((a, b) => b.score - a.score) ?? [];
}); });
activeBets = computed(() => { 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(() => { 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(() => { availableFriends = computed(() => {
@@ -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 { CommonModule } from '@angular/common';
import { DataService } from '../../core/services/data.service'; import { DataService } from '../../core/services/data.service';
import { User } from '../../core/models/user.model';
import { GroupDetailComponent } from './group-detail/group-detail.component'; import { GroupDetailComponent } from './group-detail/group-detail.component';
@Component({ @Component({
@@ -11,7 +10,7 @@ import { GroupDetailComponent } from './group-detail/group-detail.component';
templateUrl: './groups.component.html', templateUrl: './groups.component.html',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class GroupsComponent { export class GroupsComponent implements OnInit {
private dataService = inject(DataService); private dataService = inject(DataService);
groups = this.dataService.userGroups; groups = this.dataService.userGroups;
@@ -27,6 +26,12 @@ export class GroupsComponent {
return this.newGroupName().trim().length > 0 && Object.keys(this.selectedFriendIds()).length > 0; 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) { selectGroup(groupId: string) {
this.selectedGroupId.set(groupId); this.selectedGroupId.set(groupId);
} }
@@ -1,7 +1,6 @@
import { Component, ChangeDetectionStrategy, inject, signal, OnInit } from '@angular/core'; import { Component, ChangeDetectionStrategy, inject, signal, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { DataService } from '../../core/services/data.service'; 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 { BetsByTimeComponent } from './bets-by-time/bets-by-time.component';
import { BetsByGroupComponent } from './bets-by-group/bets-by-group.component'; import { BetsByGroupComponent } from './bets-by-group/bets-by-group.component';
@@ -16,12 +15,10 @@ export class HomeComponent implements OnInit {
private dataService = inject(DataService); private dataService = inject(DataService);
viewMode = signal<'time' | 'group'>('time'); viewMode = signal<'time' | 'group'>('time');
allBets = signal<Bet[]>([]); readonly allBets = this.dataService.getActiveBets;
ngOnInit() { ngOnInit() {
// FIX: getActiveBets is a computed signal which returns a value when called once. this.dataService.loadUserBets();
// The extra parentheses caused an error because it tried to execute an array.
this.allBets.set(this.dataService.getActiveBets());
} }
setViewMode(mode: 'time' | 'group') { setViewMode(mode: 'time' | 'group') {
@@ -3,7 +3,7 @@
<!-- Header with Logout --> <!-- Header with Logout -->
<header class="flex justify-between items-center mb-4"> <header class="flex justify-between items-center mb-4">
<h1 class="text-3xl font-bold">Profilo</h1> <h1 class="text-3xl font-bold">Profilo</h1>
<button title="Logout" class="p-2 text-slate-400 hover:text-white rounded-full hover:bg-slate-700 transition-colors"> <button (click)="logout()" title="Logout" class="p-2 text-slate-400 hover:text-white rounded-full hover:bg-slate-700 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" /> <path stroke-linecap="round" stroke-linejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg> </svg>
@@ -1,6 +1,7 @@
import { Component, ChangeDetectionStrategy, inject, signal } from '@angular/core'; import { Component, ChangeDetectionStrategy, inject, signal, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { DataService } from '../../core/services/data.service'; import { DataService } from '../../core/services/data.service';
import { AuthService } from '../../core/services/auth.service';
import { EditProfileComponent } from './edit-profile/edit-profile.component'; import { EditProfileComponent } from './edit-profile/edit-profile.component';
import { FriendRequest } from '../../core/models/friend-request.model'; import { FriendRequest } from '../../core/models/friend-request.model';
@@ -11,8 +12,9 @@ import { FriendRequest } from '../../core/models/friend-request.model';
templateUrl: './profile.component.html', templateUrl: './profile.component.html',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class ProfileComponent { export class ProfileComponent implements OnInit {
private dataService = inject(DataService); private dataService = inject(DataService);
private authService = inject(AuthService);
currentUser = this.dataService.currentUser; currentUser = this.dataService.currentUser;
friends = this.dataService.friends; friends = this.dataService.friends;
@@ -26,6 +28,12 @@ export class ProfileComponent {
addFriendError = signal<string | null>(null); addFriendError = signal<string | null>(null);
addFriendSuccess = signal<string | null>(null); addFriendSuccess = signal<string | null>(null);
ngOnInit() {
this.dataService.loadCurrentUser();
this.dataService.loadFriends();
this.dataService.loadFriendRequests();
}
toggleEditMode() { toggleEditMode() {
this.isEditing.update(val => !val); this.isEditing.update(val => !val);
} }
@@ -74,4 +82,8 @@ export class ProfileComponent {
declineRequest(requestId: string) { declineRequest(requestId: string) {
this.dataService.declineFriendRequest(requestId); this.dataService.declineFriendRequest(requestId);
} }
logout() {
this.authService.logout();
}
} }
+53
View File
@@ -0,0 +1,53 @@
{
"id": "scommettiapp",
"realm": "scommettiapp",
"enabled": true,
"loginTheme": "scommettiapp",
"registrationAllowed": true,
"loginWithEmailAllowed": true,
"duplicateEmailsAllowed": false,
"resetPasswordAllowed": true,
"editUsernameAllowed": false,
"clients": [
{
"clientId": "scommetti-frontend",
"enabled": true,
"publicClient": true,
"protocol": "openid-connect",
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": false,
"redirectUris": [
"http://localhost:4200/*"
],
"webOrigins": [
"http://localhost:4200"
],
"attributes": {
"pkce.code.challenge.method": "S256"
}
},
{
"clientId": "scommetti-backend",
"enabled": true,
"publicClient": false,
"secret": "your-backend-client-secret-123",
"protocol": "openid-connect",
"standardFlowEnabled": false,
"serviceAccountsEnabled": true
}
],
"roles": {
"realm": [
{
"name": "user",
"description": "Base user role"
},
{
"name": "admin",
"description": "Administrator role"
}
]
},
"defaultRoles": ["user"]
}
@@ -0,0 +1,189 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ScommettiApp - Accedi</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus {
-webkit-text-fill-color: #f1f5f9;
-webkit-box-shadow: 0 0 0px 1000px #0f172a inset;
transition: background-color 5000s ease-in-out 0s;
}
.fade-in {
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
</head>
<body class="bg-[#0f172a] min-h-screen flex items-center justify-center p-4">
<div class="w-full max-w-md fade-in">
<!-- Logo / Header -->
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-16 h-16 bg-cyan-500 rounded-2xl mb-4 shadow-lg shadow-cyan-500/20">
<svg class="w-9 h-9 text-[#0f172a]" viewBox="0 0 24 24" fill="currentColor">
<rect x="2" y="2" width="9" height="9" rx="2"/>
<rect x="13" y="2" width="9" height="9" rx="2"/>
<rect x="2" y="13" width="9" height="9" rx="2"/>
<rect x="13" y="13" width="9" height="9" rx="2"/>
<circle cx="6.5" cy="6.5" r="1.2" fill="#06b6d4"/>
<circle cx="17.5" cy="6.5" r="1.2" fill="#06b6d4"/>
<circle cx="6.5" cy="17.5" r="1.2" fill="#06b6d4"/>
<circle cx="17.5" cy="17.5" r="1.2" fill="#06b6d4"/>
</svg>
</div>
<h1 class="text-2xl font-bold text-white tracking-tight">ScommettiApp</h1>
<p class="text-[#94a3b8] text-sm mt-1">Sfida i tuoi amici</p>
</div>
<!-- Card -->
<div class="bg-[#1e293b] border border-[#334155] rounded-2xl p-8 shadow-xl">
<h2 class="text-lg font-semibold text-white mb-6">Accedi al tuo account</h2>
<!-- Messaggio globale (errore / info) -->
<#if message?has_content>
<div class="rounded-xl p-4 mb-6 text-sm
<#if message.type == 'error'>bg-red-500/10 border border-red-500/30 text-red-400
<#elseif message.type == 'success'>bg-green-500/10 border border-green-500/30 text-green-400
<#elseif message.type == 'warning'>bg-yellow-500/10 border border-yellow-500/30 text-yellow-400
<#else>bg-blue-500/10 border border-blue-500/30 text-blue-400</#if>">
${message.summary?no_esc}
</div>
</#if>
<form action="${url.loginAction}" method="post" novalidate>
<!-- Campo username / email -->
<div class="mb-5">
<label for="username" class="block text-[#cbd5e1] text-sm font-medium mb-2">
<#if realm.loginWithEmailAllowed && !realm.registrationEmailAsUsername>
Email o Username
<#elseif realm.loginWithEmailAllowed>
Email
<#else>
Username
</#if>
</label>
<input
type="text"
id="username"
name="username"
value="${(login.username!'')}"
class="w-full bg-[#0f172a] border border-[#334155] rounded-xl px-4 py-3 text-[#f1f5f9] placeholder-[#475569] focus:outline-none focus:border-cyan-500 transition-colors text-sm"
placeholder="<#if realm.loginWithEmailAllowed>nome@esempio.it<#else>il tuo username</#if>"
autocomplete="username"
autofocus
/>
</div>
<!-- Campo password -->
<div class="mb-5">
<label for="password" class="block text-[#cbd5e1] text-sm font-medium mb-2">
Password
</label>
<div class="relative">
<input
type="password"
id="password"
name="password"
class="w-full bg-[#0f172a] border border-[#334155] rounded-xl px-4 py-3 pr-12 text-[#f1f5f9] placeholder-[#475569] focus:outline-none focus:border-cyan-500 transition-colors text-sm"
placeholder="La tua password"
autocomplete="current-password"
/>
<button
type="button"
onclick="togglePassword('password', this)"
tabindex="-1"
class="absolute right-3 top-1/2 -translate-y-1/2 text-[#475569] hover:text-[#94a3b8] transition-colors p-1"
>
<svg id="eye-icon-password" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
</button>
</div>
</div>
<!-- Ricordami + Password dimenticata -->
<div class="flex items-center justify-between mb-6">
<#if realm.rememberMe>
<label class="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
id="rememberMe"
name="rememberMe"
<#if login.rememberMe??>checked</#if>
class="w-4 h-4 rounded border-[#334155] bg-[#0f172a] accent-cyan-500 cursor-pointer"
/>
<span class="text-[#94a3b8] text-sm">Ricordami</span>
</label>
<#else>
<span></span>
</#if>
<#if realm.resetPasswordAllowed>
<a href="${url.loginResetCredentialsUrl}"
class="text-cyan-400 text-sm hover:text-cyan-300 transition-colors">
Password dimenticata?
</a>
</#if>
</div>
<!-- Bottone accedi -->
<button
type="submit"
class="w-full bg-cyan-500 hover:bg-cyan-400 active:bg-cyan-600 text-[#0f172a] font-semibold py-3 rounded-xl transition-colors text-sm"
>
Accedi
</button>
</form>
<!-- Link registrazione -->
<#if realm.registrationAllowed>
<p class="text-center text-[#94a3b8] mt-6 text-sm">
Non hai un account?
<a href="${url.registrationUrl}"
class="text-cyan-400 hover:text-cyan-300 transition-colors font-medium ml-1">
Registrati
</a>
</p>
</#if>
</div>
</div>
<script>
function togglePassword(inputId, btn) {
const input = document.getElementById(inputId);
const isHidden = input.type === 'password';
input.type = isHidden ? 'text' : 'password';
const svg = btn.querySelector('svg');
if (isHidden) {
svg.innerHTML = `
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7
a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243
M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29
M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7
a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>`;
} else {
svg.innerHTML = `
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7
-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>`;
}
}
</script>
</body>
</html>
@@ -0,0 +1,264 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ScommettiApp - Registrati</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus {
-webkit-text-fill-color: #f1f5f9;
-webkit-box-shadow: 0 0 0px 1000px #0f172a inset;
transition: background-color 5000s ease-in-out 0s;
}
.fade-in {
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.field-error {
border-color: #f87171 !important;
}
</style>
</head>
<body class="bg-[#0f172a] min-h-screen flex items-center justify-center p-4 py-10">
<div class="w-full max-w-md fade-in">
<!-- Logo / Header -->
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-16 h-16 bg-cyan-500 rounded-2xl mb-4 shadow-lg shadow-cyan-500/20">
<svg class="w-9 h-9 text-[#0f172a]" viewBox="0 0 24 24" fill="currentColor">
<rect x="2" y="2" width="9" height="9" rx="2"/>
<rect x="13" y="2" width="9" height="9" rx="2"/>
<rect x="2" y="13" width="9" height="9" rx="2"/>
<rect x="13" y="13" width="9" height="9" rx="2"/>
<circle cx="6.5" cy="6.5" r="1.2" fill="#06b6d4"/>
<circle cx="17.5" cy="6.5" r="1.2" fill="#06b6d4"/>
<circle cx="6.5" cy="17.5" r="1.2" fill="#06b6d4"/>
<circle cx="17.5" cy="17.5" r="1.2" fill="#06b6d4"/>
</svg>
</div>
<h1 class="text-2xl font-bold text-white tracking-tight">ScommettiApp</h1>
<p class="text-[#94a3b8] text-sm mt-1">Crea il tuo account</p>
</div>
<!-- Card -->
<div class="bg-[#1e293b] border border-[#334155] rounded-2xl p-8 shadow-xl">
<h2 class="text-lg font-semibold text-white mb-6">Registrazione</h2>
<!-- Messaggio globale -->
<#if message?has_content>
<div class="rounded-xl p-4 mb-6 text-sm
<#if message.type == 'error'>bg-red-500/10 border border-red-500/30 text-red-400
<#elseif message.type == 'success'>bg-green-500/10 border border-green-500/30 text-green-400
<#elseif message.type == 'warning'>bg-yellow-500/10 border border-yellow-500/30 text-yellow-400
<#else>bg-blue-500/10 border border-blue-500/30 text-blue-400</#if>">
${message.summary?no_esc}
</div>
</#if>
<form action="${url.registrationAction}" method="post" novalidate>
<!-- Nome e Cognome affiancati -->
<div class="grid grid-cols-2 gap-4 mb-5">
<div>
<label for="firstName" class="block text-[#cbd5e1] text-sm font-medium mb-2">
Nome
</label>
<input
type="text"
id="firstName"
name="firstName"
value="${(register.firstName!'')}"
class="w-full bg-[#0f172a] border rounded-xl px-4 py-3 text-[#f1f5f9] placeholder-[#475569] focus:outline-none focus:border-cyan-500 transition-colors text-sm
<#if messagesPerField.existsError('firstName')>field-error border-red-400<#else>border-[#334155]</#if>"
placeholder="Mario"
autocomplete="given-name"
/>
<#if messagesPerField.existsError('firstName')>
<p class="text-red-400 text-xs mt-1">${messagesPerField.get('firstName')?no_esc}</p>
</#if>
</div>
<div>
<label for="lastName" class="block text-[#cbd5e1] text-sm font-medium mb-2">
Cognome
</label>
<input
type="text"
id="lastName"
name="lastName"
value="${(register.lastName!'')}"
class="w-full bg-[#0f172a] border rounded-xl px-4 py-3 text-[#f1f5f9] placeholder-[#475569] focus:outline-none focus:border-cyan-500 transition-colors text-sm
<#if messagesPerField.existsError('lastName')>field-error border-red-400<#else>border-[#334155]</#if>"
placeholder="Rossi"
autocomplete="family-name"
/>
<#if messagesPerField.existsError('lastName')>
<p class="text-red-400 text-xs mt-1">${messagesPerField.get('lastName')?no_esc}</p>
</#if>
</div>
</div>
<!-- Email -->
<div class="mb-5">
<label for="email" class="block text-[#cbd5e1] text-sm font-medium mb-2">
Email
</label>
<input
type="email"
id="email"
name="email"
value="${(register.email!'')}"
class="w-full bg-[#0f172a] border rounded-xl px-4 py-3 text-[#f1f5f9] placeholder-[#475569] focus:outline-none focus:border-cyan-500 transition-colors text-sm
<#if messagesPerField.existsError('email')>field-error border-red-400<#else>border-[#334155]</#if>"
placeholder="nome@esempio.it"
autocomplete="email"
/>
<#if messagesPerField.existsError('email')>
<p class="text-red-400 text-xs mt-1">${messagesPerField.get('email')?no_esc}</p>
</#if>
</div>
<!-- Username (solo se non usa email come username) -->
<#if !realm.registrationEmailAsUsername>
<div class="mb-5">
<label for="username" class="block text-[#cbd5e1] text-sm font-medium mb-2">
Username
</label>
<input
type="text"
id="username"
name="username"
value="${(register.username!'')}"
class="w-full bg-[#0f172a] border rounded-xl px-4 py-3 text-[#f1f5f9] placeholder-[#475569] focus:outline-none focus:border-cyan-500 transition-colors text-sm
<#if messagesPerField.existsError('username')>field-error border-red-400<#else>border-[#334155]</#if>"
placeholder="il tuo username"
autocomplete="username"
/>
<#if messagesPerField.existsError('username')>
<p class="text-red-400 text-xs mt-1">${messagesPerField.get('username')?no_esc}</p>
</#if>
</div>
</#if>
<!-- Password -->
<div class="mb-5">
<label for="password" class="block text-[#cbd5e1] text-sm font-medium mb-2">
Password
</label>
<div class="relative">
<input
type="password"
id="password"
name="password"
class="w-full bg-[#0f172a] border rounded-xl px-4 py-3 pr-12 text-[#f1f5f9] placeholder-[#475569] focus:outline-none focus:border-cyan-500 transition-colors text-sm
<#if messagesPerField.existsError('password')>field-error border-red-400<#else>border-[#334155]</#if>"
placeholder="Almeno 8 caratteri"
autocomplete="new-password"
/>
<button
type="button"
onclick="togglePassword('password', this)"
tabindex="-1"
class="absolute right-3 top-1/2 -translate-y-1/2 text-[#475569] hover:text-[#94a3b8] transition-colors p-1"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7
-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
</button>
</div>
<#if messagesPerField.existsError('password')>
<p class="text-red-400 text-xs mt-1">${messagesPerField.get('password')?no_esc}</p>
</#if>
</div>
<!-- Conferma password -->
<div class="mb-7">
<label for="password-confirm" class="block text-[#cbd5e1] text-sm font-medium mb-2">
Conferma password
</label>
<div class="relative">
<input
type="password"
id="password-confirm"
name="password-confirm"
class="w-full bg-[#0f172a] border rounded-xl px-4 py-3 pr-12 text-[#f1f5f9] placeholder-[#475569] focus:outline-none focus:border-cyan-500 transition-colors text-sm
<#if messagesPerField.existsError('password-confirm')>field-error border-red-400<#else>border-[#334155]</#if>"
placeholder="Ripeti la password"
autocomplete="new-password"
/>
<button
type="button"
onclick="togglePassword('password-confirm', this)"
tabindex="-1"
class="absolute right-3 top-1/2 -translate-y-1/2 text-[#475569] hover:text-[#94a3b8] transition-colors p-1"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7
-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
</button>
</div>
<#if messagesPerField.existsError('password-confirm')>
<p class="text-red-400 text-xs mt-1">${messagesPerField.get('password-confirm')?no_esc}</p>
</#if>
</div>
<!-- Bottone registrati -->
<button
type="submit"
class="w-full bg-cyan-500 hover:bg-cyan-400 active:bg-cyan-600 text-[#0f172a] font-semibold py-3 rounded-xl transition-colors text-sm"
>
Crea account
</button>
</form>
<!-- Link accedi -->
<p class="text-center text-[#94a3b8] mt-6 text-sm">
Hai già un account?
<a href="${url.loginUrl}"
class="text-cyan-400 hover:text-cyan-300 transition-colors font-medium ml-1">
Accedi
</a>
</p>
</div>
</div>
<script>
function togglePassword(inputId, btn) {
const input = document.getElementById(inputId);
const isHidden = input.type === 'password';
input.type = isHidden ? 'text' : 'password';
const svg = btn.querySelector('svg');
if (isHidden) {
svg.innerHTML = `
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7
a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243
M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29
M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7
a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>`;
} else {
svg.innerHTML = `
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7
-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>`;
}
}
</script>
</body>
</html>
@@ -0,0 +1,2 @@
parent=keycloak
import=common/keycloak