diff --git a/.github/workflows/web-actions.yml b/.github/workflows/web-actions.yml index 0ed5f6954..45970672f 100644 --- a/.github/workflows/web-actions.yml +++ b/.github/workflows/web-actions.yml @@ -126,8 +126,8 @@ jobs: kubectl set env deployment/cc-web-deploy SMTP_PASSWORD=${{secrets.SMTP_PASSWORD}} kubectl set env deployment/cc-web-deploy NEXTAUTH_SECRET=${{secrets.NEXTAUTH_SECRET}} kubectl set env deployment/cc-web-deploy RESET_TOKEN_SECRET=${{secrets.RESET_TOKEN_SECRET}} + kubectl set env deployment/cc-web-deploy VERIFICATION_TOKEN_SECRET=${{secrets.VERIFICATION_TOKEN_SECRET}} kubectl set env deployment/cc-web-deploy CHAT_PROVIDER=huggingface kubectl set env deployment/cc-web-deploy OPENAI_API_KEY=${{secrets.OPENAI_API_KEY}} kubectl set env deployment/cc-web-deploy HUGGINGFACE_API_KEY=${{secrets.HUGGINGFACE_API_KEY}} kubectl rollout restart deployment cc-web-deploy -n default - diff --git a/app/migrations/20240213184421-city-invite.cjs b/app/migrations/20240213184421-city-invite.cjs new file mode 100644 index 000000000..08287e683 --- /dev/null +++ b/app/migrations/20240213184421-city-invite.cjs @@ -0,0 +1,53 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up (queryInterface, Sequelize) { + await queryInterface.createTable('CityInvite', { + id: { + type: Sequelize.UUID, + primaryKey:true, + }, + city_id: { + type: Sequelize.UUID, + allowNull: true, + references: { + model:'City', + key: 'city_id' + } + }, + user_id: { + type: Sequelize.STRING, + allowNull:true, + }, + inviting_user_id: { + type: Sequelize.UUID, + allowNull: true, + references: { + model:'User', + key: 'user_id' + } + }, + status: { + type: Sequelize.STRING, + allowNull:false, + defaultValue: 'pending' + }, + created: { + type: Sequelize.DATE, + allowNull: true, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'), + }, + last_updated: { + type: Sequelize.DATE, + allowNull: true, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'), + onUpdate: Sequelize.literal('CURRENT_TIMESTAMP'), + } + }) + }, + + async down (queryInterface, Sequelize) { + await queryInterface.dropTable('CityInvite'); + } +}; diff --git a/app/src/app/[lng]/settings/page.tsx b/app/src/app/[lng]/settings/page.tsx index 14eab20ad..a70d0b402 100644 --- a/app/src/app/[lng]/settings/page.tsx +++ b/app/src/app/[lng]/settings/page.tsx @@ -135,6 +135,7 @@ export default function Settings({ userInfo={userInfo} cities={cities} cityUsers={cityUsers} + defaultCityId={cityId} /> { - const body = createUserRequest.parse(await _req.json()); +export const POST = apiHandler(async (req, { params, session }) => { + const body = await req.json(); - const city = await UserService.findUserCity(params.city, session); + // check if the user exists - // TODO shouldn't the users sign up themselves? This will probably prevent signup - const user = await db.models.User.create({ - userId: randomUUID(), - ...body, + const existingUser = await db.models.User.findOne({ + where: { email: body.email! }, }); - user.addCity(city.cityId); - return NextResponse.json({ data: user }); + if (!existingUser) { + // return a message to ui for the flow to continue and not break + return NextResponse.json({ message: "User not found" }); + } + + return NextResponse.json({ data: existingUser }); }); -export const GET = apiHandler(async (_req, { params, session }) => { +export const GET = apiHandler(async (req, { params, session }) => { const city = await UserService.findUserCity(params.city, session); const users = await db.models.User.findAll({ diff --git a/app/src/app/api/v0/city/invite/[invite]/route.ts b/app/src/app/api/v0/city/invite/[invite]/route.ts new file mode 100644 index 000000000..7263c8ce6 --- /dev/null +++ b/app/src/app/api/v0/city/invite/[invite]/route.ts @@ -0,0 +1,49 @@ +import { db } from "@/models"; +import { apiHandler } from "@/util/api"; +import createHttpError from "http-errors"; +import { Session } from "next-auth"; +import { NextRequest, NextResponse } from "next/server"; +import jwt from "jsonwebtoken"; + +export const GET = apiHandler(async (req, { params, session }) => { + const invite = await db.models.CityInvite.findOne({ + where: { + id: params.invite, + }, + }); + + if (!invite) { + throw new createHttpError.NotFound("Not found"); + } + + const token = req.nextUrl.searchParams.get("token"); + const email = req.nextUrl.searchParams.get("email"); + + const isVerified = jwt.verify(token!, process.env.VERIFICATION_TOKEN_SECRET!); + + if (!isVerified) { + throw new createHttpError.BadRequest("Invalid token"); + } + + await invite.update({ + status: "accepted", + }); + + const user = await db.models.User.findOne({ + where: { + email: email!, + }, + }); + + if (!user) { + return NextResponse.redirect("/"); + } + + const city = await db.models.City.findOne({ + where: { cityId: invite.cityId }, + }); + + await user?.addCity(city?.cityId); + + return NextResponse.redirect("/"); +}); diff --git a/app/src/app/api/v0/city/invite/route.ts b/app/src/app/api/v0/city/invite/route.ts new file mode 100644 index 000000000..80a5a8c75 --- /dev/null +++ b/app/src/app/api/v0/city/invite/route.ts @@ -0,0 +1,68 @@ +import { db } from "@/models"; +import { apiHandler } from "@/util/api"; +import { createUserInvite } from "@/util/validation"; +import { randomUUID } from "crypto"; +import createHttpError from "http-errors"; +import { NextResponse } from "next/server"; +import jwt from "jsonwebtoken"; +import { sendEmail } from "@/lib/email"; +import { render } from "@react-email/components"; +import InviteUserTemplate from "@/lib/emails/InviteUserTemplate"; +import UserService from "@/backend/UserService"; + +export const POST = apiHandler(async (req, { params, session }) => { + const body = createUserInvite.parse(await req.json()); + const city = await UserService.findUserCity(body.cityId, session); + + const cityData = await db.models.City.findOne({ + where: { cityId: city.cityId }, + }); + + if (!cityData) { + throw new createHttpError.NotFound("City not found"); + } + + if (!process.env.VERIFICATION_TOKEN_SECRET) { + console.error("Need to assign RESET_TOKEN_SECRET in env!"); + throw createHttpError.InternalServerError("Configuration error"); + } + + const invitationCode = jwt.sign( + { email: body.email, reason: "invite", city: body.cityId }, + process.env.VERIFICATION_TOKEN_SECRET, + { + expiresIn: "24h", + }, + ); + + const invite = await db.models.CityInvite.create({ + id: randomUUID(), + ...body, + }); + + if (!invite) { + throw new createHttpError.BadRequest("Something went wrong"); + } + const host = process.env.HOST ?? "http://localhost:3000"; + const sendInvite = await sendEmail({ + to: body.email!, + subject: "City Catalyst - City Invitation", + html: render( + InviteUserTemplate({ + url: `${host}/api/v0/city/invite/${invite.id}?token=${invitationCode}&email=${body.email}`, + user: { email: body.email, name: body.name }, + city, + invitingUser: { + name: session?.user.name!, + email: session?.user.email!, + }, + members: city.users, + }), + ), + }); + + if (!sendInvite) + throw new createHttpError.BadRequest("Email could not be sent"); + + return NextResponse.json({ data: invite }); +}); diff --git a/app/src/components/Modals/add-user-modal.tsx b/app/src/components/Modals/add-user-modal.tsx index f96c5790b..a498ef15e 100644 --- a/app/src/components/Modals/add-user-modal.tsx +++ b/app/src/components/Modals/add-user-modal.tsx @@ -27,35 +27,39 @@ import { TFunction } from "i18next"; interface AddUserModalProps { isOpen: boolean; onClose: () => void; - cityId: string | undefined; t: TFunction; + userInfo: UserAttributes | null; + defaultCityId?: string; } const AddUserModal: FC = ({ isOpen, onClose, t, - cityId, + userInfo, + defaultCityId, }) => { const { handleSubmit, register, formState: { errors }, } = useForm(); - const [addUser] = api.useAddUserMutation(); + const [checkUser] = api.useCheckUserMutation(); + const [inviteUser, { isLoading: isInviteLoading }] = + api.useInviteUserMutation(); const [inputValue, setInputValue] = useState(""); + const [userId, setUserId] = useState(""); const toast = useToast(); const onInputChange = (e: any) => { setInputValue(e.target.value); }; - const onSubmit: SubmitHandler = async (data) => { - await addUser({ - name: data.name!, + const onSubmit: SubmitHandler<{ name: string; email: string }> = async ( + data, + ) => { + await checkUser({ email: data.email!, - role: data.role!, - cityId: cityId!, - }).then((res: any) => { - console.log(res); + cityId: defaultCityId!, + }).then(async (res: any) => { if (res.error) { return toast({ description: t("something-went-wrong"), @@ -91,48 +95,91 @@ const AddUserModal: FC = ({ ), }); } else { - onClose(); - return toast({ - description: t("user-details-updated"), - status: "success", - duration: 5000, - isClosable: true, - render: () => ( - - - - - { + onClose(); + if (res?.error?.status == 400) { + return toast({ + description: "Something went wrong", + status: "error", + duration: 5000, + isClosable: true, + render: () => ( + - {t("user-details-updated")} - - - - ), + + + + Something went wrong! + + + + ), + }); + } else { + return toast({ + description: "User invite sent", + status: "success", + duration: 5000, + isClosable: true, + render: () => ( + + + + + User invite sent + + + + ), + }); + } }); } }); }; + return ( <> - + = ({ label={t("email")} register={register} /> - - - + /> */} @@ -204,6 +242,7 @@ const AddUserModal: FC = ({ fontWeight="semibold" fontSize="button.md" type="submit" + isLoading={isInviteLoading} onClick={handleSubmit(onSubmit)} p={0} m={0} diff --git a/app/src/components/Tabs/my-profile-tab.tsx b/app/src/components/Tabs/my-profile-tab.tsx index 7a8b5444a..00cea56b8 100644 --- a/app/src/components/Tabs/my-profile-tab.tsx +++ b/app/src/components/Tabs/my-profile-tab.tsx @@ -74,6 +74,7 @@ interface MyProfileTabProps { userInfo: UserAttributes | any; cityUsers: UserAttributes[] | any; cities: CityAttributes[] | any; + defaultCityId: string | undefined; } const MyProfileTab: FC = ({ @@ -84,6 +85,7 @@ const MyProfileTab: FC = ({ userInfo, cityUsers, cities, + defaultCityId, }) => { const [inputValue, setInputValue] = useState(""); const { @@ -106,7 +108,7 @@ const MyProfileTab: FC = ({ const toast = useToast(); const onSubmit: SubmitHandler = async (data) => { await setCurrentUserData({ - cityId: "", // TODO pass currently selected city's ID in here! + cityId: defaultCityId!, userId: userInfo.userId, name: data.name, email: data.email, @@ -242,7 +244,7 @@ const MyProfileTab: FC = ({ selectedUsers.map(async (user: string) => { await removeUser({ userId: user, - cityId: "", // TODO pass currently selected city ID into this component + cityId: defaultCityId!, }).then((res: any) => { if (res.data.deleted) { toast({ @@ -934,9 +936,6 @@ const MyProfileTab: FC = ({ background: "content.link", color: "white", }} - onClick={() => { - alert(city.cityId); - }} > @@ -1003,7 +1002,8 @@ const MyProfileTab: FC = ({ + + + + + CityCatalyst: City Invitation + + + City Catalyst logo + CityCatalyst + Join Your Team In CityCatalyst + Hi {user?.name}, + + {" "} + {invitingUser?.name} ({invitingUser?.email}) has invited you to join + CityCatalyst and contribute to the emission inventory for the city. + +
+
+
+ + {city?.name} + + + {members.length} member(s) + +
+
+
+ + JOIN NOW + +
+ +
+ + Open Earth Foundation is a nonprofit public benefit corporation from + California, USA. EIN: 85-3261449 + + + + + ); +} + +// Styles for the email template +const main = { + backgroundColor: "#ffffff", + fontFamily: + '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif', +}; + +const container = { + margin: "0 auto", + padding: "20px 0 48px", + width: "580px", +}; + +const brandHeading = { + fontSize: "20px", + lineHeight: "1.5", + fontWeight: "700", + color: "#2351DC", +}; + +const heading = { + fontSize: "24px", + lineHeight: "1.3", + fontWeight: "700", + color: "#484848", + marginTop: "50px", +}; + +const greeting = { + fontSize: "14px", + lineHeight: "1.4", + color: "#484848", +}; + +const paragraph = { + fontSize: "14px", + lineHeight: "1.4", + color: "#484848", +}; + +const cityBox = { + display: "flex", + paddingLeft: "16px", + alignItems: "center", + gap: "16px", + height: "80px", + borderRadius: "8px", + border: "1px solid #E6E7FF", + marginTop: "36px", +}; + +const urlLink = { + fontSize: "14px", + padding: "16px", + backgroundColor: "#2351DC", + borderRadius: "100px", + lineHeight: 1.5, + color: "#FFF", + marginTop: "36px", +}; + +const footerText = { + fontSize: "12px", + lineHeight: "16px", + fontWeight: "400", + color: "#79797A", +}; diff --git a/app/src/models/CityInvite.ts b/app/src/models/CityInvite.ts new file mode 100644 index 000000000..df31a80db --- /dev/null +++ b/app/src/models/CityInvite.ts @@ -0,0 +1,102 @@ +import * as Sequelize from "sequelize"; +import { DataTypes, Model, Optional } from "sequelize"; +import { City, CityId } from "./City"; +import { UserFileCreationAttributes } from "./UserFile"; + +export interface CityInviteAttributes { + id: string; + cityId?: string; + userId?: string; + invitingUserId?: string; + status?: string; + created?: Date; + lastUpdated?: Date; +} + +export type CityInvitePk = "id"; +export type CityInviteId = CityInvite[CityInvitePk]; +export type CityInviteCreationAttributes = Optional< + CityInviteAttributes, + CityInviteOptionalAttributes +>; +export type CityInviteOptionalAttributes = + | "cityId" + | "userId" + | "invitingUserId" + | "status" + | "created" + | "lastUpdated"; + +export class CityInvite + extends Model + implements CityInviteAttributes +{ + id!: string; + cityId?: string | undefined; + userId?: string | undefined; + invitingUserId?: string | undefined; + status?: string | undefined; + created?: Date | undefined; + lastUpdated?: Date | undefined; + + // CityInvite belongs to City via cityId + city!: City; + getCity!: Sequelize.BelongsToGetAssociationMixin; + setCity!: Sequelize.BelongsToSetAssociationMixin; + createCity!: Sequelize.BelongsToCreateAssociationMixin; + + static initModel(sequelize: Sequelize.Sequelize): typeof CityInvite { + return CityInvite.init( + { + id: { + type: DataTypes.UUID, + allowNull: false, + primaryKey: true, + field: "id", + }, + cityId: { + type: DataTypes.UUID, + allowNull: true, + references: { + model: "City", + key: "city_id", + }, + field: "city_id", + }, + userId: { + type: DataTypes.STRING(255), + allowNull: true, + }, + invitingUserId: { + type: DataTypes.UUID, + allowNull: true, + references: { + model: "User", + key: "user_id", + }, + field: "inviting_user_id", + }, + status: { + type: DataTypes.STRING(255), + allowNull: true, + }, + }, + { + sequelize, + underscored: true, + tableName: "CityInvite", + schema: "public", + timestamps: true, + createdAt: "created", + updatedAt: "last_updated", + indexes: [ + { + name: "CityInvite_pkey", + unique: true, + fields: [{ name: "id" }], + }, + ], + }, + ); + } +} diff --git a/app/src/models/init-models.ts b/app/src/models/init-models.ts index 927da7122..6ff4f8f05 100644 --- a/app/src/models/init-models.ts +++ b/app/src/models/init-models.ts @@ -124,6 +124,7 @@ import type { UserAttributes, UserCreationAttributes } from "./User"; import { Version as _Version } from "./Version"; import type { VersionAttributes, VersionCreationAttributes } from "./Version"; import { UserFile as _UserFile } from "./UserFile"; +import { CityInvite as _CityInvite } from "./CityInvite"; export { _ActivityData as ActivityData, @@ -156,6 +157,7 @@ export { _User as User, _Version as Version, _UserFile as UserFile, + _CityInvite as CityInvite, }; export type { @@ -252,6 +254,7 @@ export function initModels(sequelize: Sequelize) { const User = _User.initModel(sequelize); const Version = _Version.initModel(sequelize); const UserFile = _UserFile.initModel(sequelize); + const CityInvite = _CityInvite.initModel(sequelize); ActivityData.belongsToMany(DataSource, { as: "datasourceIdDataSources", @@ -598,6 +601,8 @@ export function initModels(sequelize: Sequelize) { }); User.hasMany(UserFile, { foreignKey: "userId", as: "user" }); UserFile.belongsTo(User, { as: "userFiles", foreignKey: "userId" }); + City.hasMany(CityInvite, { as: "cityInvite", foreignKey: "cityId" }); + CityInvite.belongsTo(City, { as: "cityInvites", foreignKey: "cityId" }); GasValue.belongsTo(InventoryValue, { as: "inventoryValue", foreignKey: "inventoryValueId", @@ -654,5 +659,6 @@ export function initModels(sequelize: Sequelize) { User: User, Version: Version, UserFile: UserFile, + CityInvite: CityInvite, }; } diff --git a/app/src/services/api.ts b/app/src/services/api.ts index 51e689bb2..5f3900276 100644 --- a/app/src/services/api.ts +++ b/app/src/services/api.ts @@ -18,6 +18,7 @@ import type { UserInfoResponse, UserFileResponse, EmissionsFactorResponse, + UserInviteResponse, } from "@/util/types"; import type { GeoJSON } from "geojson"; import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"; @@ -88,7 +89,8 @@ export const api = createApi({ method: "POST", body: data, }), - transformResponse: (response: { data: InventoryAttributes }) => response.data, + transformResponse: (response: { data: InventoryAttributes }) => + response.data, invalidatesTags: ["UserInventories"], }), setUserInfo: builder.mutation< @@ -228,12 +230,10 @@ export const api = createApi({ body: data, }), }), - addUser: builder.mutation< + checkUser: builder.mutation< UserAttributes, { - name: string; email: string; - role: string; cityId: string; } >({ @@ -242,6 +242,7 @@ export const api = createApi({ method: "POST", body: data, }), + transformResponse: (response: { data: any }) => response.data, invalidatesTags: ["UserData"], }), getCityUsers: builder.query< @@ -354,6 +355,28 @@ export const api = createApi({ transformResponse: (response: { data: EmissionsFactorResponse }) => response.data, }), + // User invitation to city + inviteUser: builder.mutation< + UserInviteResponse, + { + cityId: string; + name?: string; + email: string; + userId: string; + invitingUserId: string; + } + >({ + query: (data) => { + return { + method: "POST", + url: `/city/invite`, + body: data, + }; + }, + + transformResponse: (response: { data: UserInviteResponse }) => + response.data, + }), }), }); @@ -403,5 +426,7 @@ export const { useAddUserFileMutation, useGetUserFilesQuery, useDeleteUserFileMutation, + useInviteUserMutation, + useCheckUserMutation, } = api; export const { useGetOCCityQuery, useGetOCCityDataQuery } = openclimateAPI; diff --git a/app/src/util/types.d.ts b/app/src/util/types.d.ts index b2cb5b0cf..1085a870c 100644 --- a/app/src/util/types.d.ts +++ b/app/src/util/types.d.ts @@ -70,7 +70,9 @@ interface InventoryValueUpdateQuery { data: InventoryValueData; } -type EmissionsFactorWithDataSources = EmissionsFactorAttributes & { dataSources: DataSourceAttributes[] }; +type EmissionsFactorWithDataSources = EmissionsFactorAttributes & { + dataSources: DataSourceAttributes[]; +}; type EmissionsFactorResponse = EmissionsFactorWithDataSources[]; type InventoryWithCity = InventoryAttributes & { city: CityAttributes }; @@ -104,3 +106,12 @@ interface UserFileResponse { file: fileContentValues; lastUpdated: string; } + +interface UserInviteResponse { + id: string; + userId: string; + locode: string; + status: string; + created: string; + lastUpdated: string; +} diff --git a/app/src/util/validation.ts b/app/src/util/validation.ts index 186e6a102..f8f63785d 100644 --- a/app/src/util/validation.ts +++ b/app/src/util/validation.ts @@ -128,3 +128,13 @@ export const createUserFileRequset = z.object({ // Schema type definition export type CreateUserFileRequetData = z.infer; + +export const createUserInvite = z.object({ + userId: z.string().optional(), + invitingUserId: z.string().uuid(), + email: z.string().email(), + name: z.string(), + cityId: z.string(), +}); + +export type CreateUserInvite = z.infer;