Compare commits

...

2 Commits

Author SHA1 Message Date
Hayden Hargreaves
38f3c87885 (FEAT): Migrated the home page APIS.
Just need a fix for the optinal authenticated user for the ROTW.
2025-11-15 23:43:20 -07:00
Hayden Hargreaves
c0b76506c4 (FEAT): Profile page APIs are complete!!!!
This also includes a shell.nix file for use just in case the flake
isn't.
2025-11-15 23:26:16 -07:00
10 changed files with 280 additions and 175 deletions

View File

@ -0,0 +1,27 @@
package server
import (
"net/http"
"github.com/gin-gonic/gin"
domain "github.com/haydenhargreaves/Potion/internal/domain/user"
)
// AuthenticatedFunc is a function that handles authenticated requests
type AuthenticatedFunc func(ctx *gin.Context, user *domain.User)
// withAuthenticatedUser is a helper to run a handler only if user is authenticated. Otherwise
// the function will return an error with a 401 status.
func (s *Server) withAuthenticatedUser(ctx *gin.Context, handler AuthenticatedFunc) {
user := s.deps.UserService.GetAuthenicatedUser(ctx)
if user == nil {
ctx.JSON(http.StatusUnauthorized, gin.H{
"status": http.StatusUnauthorized,
"message": "[UNAUTHORIZED] Could not fetch authenticated user.",
})
return
}
handler(ctx, user)
}
// TODO: Create a function to use for methods that CAN use a user, but sometimes don't.

View File

@ -19,7 +19,6 @@ import (
func JwtAuthMiddlewareV2(jwtSecretKey []byte) gin.HandlerFunc {
return func(ctx *gin.Context) {
tokenString, err := ctx.Cookie("jwt_token")
fmt.Println(tokenString)
if err != nil {
ctx.JSON(http.StatusUnauthorized, gin.H{
"status": http.StatusUnauthorized,

View File

@ -209,6 +209,10 @@ func (s *Server) Setup() *Server {
router_api_v2.GET("/user", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.GetAuthenticatedUserHandlerV2)
router_api_v2.GET("/user/recipes", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.GetAuthenicatedUserRecipesV2)
router_api_v2.GET("/user/favorites", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.GetAuthenicatedUserFavoritesV2)
router_api_v2.GET("/user/engagement", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.GetAuthenicatedUserEngagementV2)
router_api_v2.GET("/user/recipes/made", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.GetAuthenicatedUserMadeRecipesV2)
router_api_v2.GET("/user/recipes/viewed", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.GetAuthenicatedUserViewedRecipesV2)
router_api_v2.GET("/protected", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), func(ctx *gin.Context) {
ctx.JSON(http.StatusOK, gin.H{"msg": "YAY"})

View File

@ -5,35 +5,21 @@ import (
"net/http"
"github.com/gin-gonic/gin"
domain "github.com/haydenhargreaves/Potion/internal/domain/user"
)
func (s *Server) GetAuthenticatedUserHandlerV2(ctx *gin.Context) {
user := s.deps.UserService.GetAuthenicatedUser(ctx)
if user == nil {
ctx.JSON(http.StatusUnauthorized, gin.H{
"status": http.StatusUnauthorized,
"message": "[UNAUTHORIZED] Could not fetch authenticated user.",
})
return
}
s.withAuthenticatedUser(ctx, func(ctx *gin.Context, user *domain.User) {
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "[OK] Successfully retrieved authenticated user.",
"user": user,
})
})
}
func (s *Server) GetAuthenicatedUserRecipesV2(ctx *gin.Context) {
user := s.deps.UserService.GetAuthenicatedUser(ctx)
if user == nil {
ctx.JSON(http.StatusUnauthorized, gin.H{
"status": http.StatusUnauthorized,
"message": "[UNAUTHORIZED] Could not fetch authenticated user.",
})
return
}
s.withAuthenticatedUser(ctx, func(ctx *gin.Context, user *domain.User) {
recipes, err := s.deps.RecipeService.GetUserRecipes(user.Id)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
@ -48,18 +34,11 @@ func (s *Server) GetAuthenicatedUserRecipesV2(ctx *gin.Context) {
"message": "[OK] Successfully retrieved authenticated user's recipes.",
"recipes": recipes,
})
})
}
func (s *Server) GetAuthenicatedUserFavoritesV2(ctx *gin.Context) {
user := s.deps.UserService.GetAuthenicatedUser(ctx)
if user == nil {
ctx.JSON(http.StatusUnauthorized, gin.H{
"status": http.StatusUnauthorized,
"message": "[UNAUTHORIZED] Could not fetch authenticated user.",
})
return
}
s.withAuthenticatedUser(ctx, func(ctx *gin.Context, user *domain.User) {
favorites, err := s.deps.RecipeService.GetUserFavoriteRecipes(user.Id)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
@ -74,4 +53,62 @@ func (s *Server) GetAuthenicatedUserFavoritesV2(ctx *gin.Context) {
"message": "[OK] Successfully retrieved authenticated user's favorites.",
"favorites": favorites,
})
})
}
func (s *Server) GetAuthenicatedUserEngagementV2(ctx *gin.Context) {
s.withAuthenticatedUser(ctx, func(ctx *gin.Context, user *domain.User) {
engagement, err := s.deps.EngagementService.GetUserEngagement(user.Id, 6)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[FAILED] Failed to get authenticated user engagement. %s", err.Error()),
})
return
}
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "[OK] Successfully retrieved authenticated user engagement.",
"engagement": engagement,
})
})
}
func (s *Server) GetAuthenicatedUserMadeRecipesV2(ctx *gin.Context) {
s.withAuthenticatedUser(ctx, func(ctx *gin.Context, user *domain.User) {
recipes, err := s.deps.RecipeService.GetUserMadeRecipes(user.Id, 6)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[FAILED] Failed to get authenticated user's made recipes. %s", err.Error()),
})
return
}
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "[OK] Successfully retrieved authenticated user's made recipes.",
"recipes": recipes,
})
})
}
func (s *Server) GetAuthenicatedUserViewedRecipesV2(ctx *gin.Context) {
s.withAuthenticatedUser(ctx, func(ctx *gin.Context, user *domain.User) {
recipes, err := s.deps.RecipeService.GetUserViewedRecipes(user.Id, 6)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[FAILED] Failed to get authenticated user's viewed recipes. %s", err.Error()),
})
return
}
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "[OK] Successfully retrieved authenticated user's viewed recipes.",
"recipes": recipes,
})
})
}

34
shell.nix Normal file
View File

@ -0,0 +1,34 @@
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
buildInputs = with pkgs; [
go
gopls
go-tools
htmx-lsp2
templ
tailwindcss_4
tailwindcss-language-server
watchman
docker-language-server
dockerfile-language-server-nodejs
gcc_multi
glibc_multi
nodejs
];
shellHook = ''
alias vim="nvim"
alias vi="nvim"
alias v="nvim"
# Modify this
export PS1="\[\e[35m\]\w \$ \[\e[0m\]"
echo ""
echo "The default environment is ready!"
echo ""
exec zsh
'';
}

View File

@ -5,6 +5,14 @@ interface ActivityListItemProps {
engagement: Engagement;
}
function FormatDate(date: Date): string {
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "2-digit",
day: "2-digit"
}).format(date);
}
export default function ActivityListItem({ engagement }: ActivityListItemProps) {
return <>
<li className="w-full border-b border-gray-300 px-2 py-4 even:bg-gray-50 hover:bg-gray-100 duration-150 flex justify-between items-center">
@ -12,7 +20,7 @@ export default function ActivityListItem({ engagement }: ActivityListItemProps)
{engagement.Message}
</p>
<p className="text-xs md:text-sm text-gray-600 w-fit shrink-0">
{engagement.Created.toLocaleDateString()}
{FormatDate(new Date(engagement.Created))}
</p>
</li>
</>;

View File

@ -11,6 +11,7 @@ import RecipeSearchBar from "../components/inputs/RecipeSearchBar";
import { GetRecipeOfTheWeek } from "../services/RecipeService";
import { isApiError, type ApiError } from "../types/api/error";
import { AuthContext } from "../context/AuthContext";
import { GetAuthenticatedUserMadeRecipes, GetAuthenticateUserViewedRecipes } from "../services/UserService";
export default function Home() {
// Context
@ -24,104 +25,33 @@ export default function Home() {
const [error, setError] = useState<string>("");
// BUG: Remove these
useEffect(() => {
const recipe: Recipe = {
Id: 1,
Title: "Classic Pancakes",
Description: "Fluffy and delicious pancakes perfect for breakfast.",
Instructions: [
"In a bowl, mix all the dry ingredients.",
"In another bowl, whisk the wet ingredients.",
"Combine both mixes until smooth.",
"Heat a non-stick skillet and pour batter.",
"Cook until bubbles form, flip and cook the other side.",
"Serve warm with syrup."
],
Serves: 4,
Difficulty: 2, // scale 1-5 (example)
Duration: {
Total: 20,
Prep: 5,
Cook: 15
},
Category: "breakfast",
Ingredients: [
{ Name: "Flour", Quantity: "2 cups" },
{ Name: "Milk", Quantity: "1.5 cups" },
{ Name: "Egg", Quantity: "1 large" },
{ Name: "Baking Powder", Quantity: "2 teaspoons" },
{ Name: "Salt", Quantity: "0.5 teaspoon" },
{ Name: "Sugar", Quantity: "1 tablespoon" }
],
UserId: 101,
Modified: new Date("2025-10-30T09:00:00"),
Created: new Date("2025-10-01T08:30:00"),
Tags: [
{ Id: 1, Name: "easy", Created: new Date("2025-01-01T12:00:00") },
{ Id: 2, Name: "quick", Created: new Date("2025-01-02T12:00:00") },
{ Id: 3, Name: "breakfast", Created: new Date("2025-01-03T12:00:00") }
],
Favorite: true
};
const recipe2: Recipe = {
Id: 2,
Title: "Classic Pancakes",
Description: "Fluffy and delicious pancakes perfect for breakfast.",
Instructions: [
"In a bowl, mix all the dry ingredients.",
"In another bowl, whisk the wet ingredients.",
"Combine both mixes until smooth.",
"Heat a non-stick skillet and pour batter.",
"Cook until bubbles form, flip and cook the other side.",
"Serve warm with syrup."
],
Serves: 4,
Difficulty: 2, // scale 1-5 (example)
Duration: {
Total: 20,
Prep: 5,
Cook: 15
},
Category: "breakfast",
Ingredients: [
{ Name: "Flour", Quantity: "2 cups" },
{ Name: "Milk", Quantity: "1.5 cups" },
{ Name: "Egg", Quantity: "1 large" },
{ Name: "Baking Powder", Quantity: "2 teaspoons" },
{ Name: "Salt", Quantity: "0.5 teaspoon" },
{ Name: "Sugar", Quantity: "1 tablespoon" }
],
UserId: 101,
Modified: new Date("2025-10-30T09:00:00"),
Created: new Date("2025-10-01T08:30:00"),
Tags: [
{ Id: 1, Name: "easy", Created: new Date("2025-01-01T12:00:00") },
{ Id: 2, Name: "quick", Created: new Date("2025-01-02T12:00:00") },
{ Id: 3, Name: "breakfast", Created: new Date("2025-01-03T12:00:00") }
],
Favorite: true
};
setRecipeOfTheWeek(recipe);
const recipes: Recipe[] = [recipe, recipe2];
setMadeRecipes(recipes);
setViewedRecipes(recipes);
}, []);
// TODO: Fetch other items when needed
// Fetch the recipe of the week
useEffect(() => {
async function fetch() {
const result: Recipe | ApiError = await GetRecipeOfTheWeek();
if (isApiError(result)) {
setError(result.message);
return;
const result_rotw: Recipe | ApiError = await GetRecipeOfTheWeek();
if (isApiError(result_rotw)) {
setError(result_rotw.message);
} else {
setRecipeOfTheWeek(result_rotw);
}
setRecipeOfTheWeek(result);
if (isLoggedIn) {
const result_made: Recipe[] | ApiError = await GetAuthenticatedUserMadeRecipes();
if (isApiError(result_made)) {
setError(result_made.message);
} else {
setMadeRecipes(result_made);
}
const result_viewed: Recipe[] | ApiError = await GetAuthenticateUserViewedRecipes();
if (isApiError(result_viewed)) {
setError(result_viewed.message);
} else {
setViewedRecipes(result_viewed);
}
}
}
void fetch();
}, []);

View File

@ -5,7 +5,7 @@ import RecipeListItem from "../components/results/RecipeListItem";
import type { Engagement } from "../types/engagement";
import ActivityListItem from "../components/results/ActivityListItem";
import { AuthContext } from "../context/AuthContext";
import { GetAuthenticatedUser, GetAuthenticatedUserFavorites, GetAuthenticatedUserRecipes } from "../services/UserService";
import { GetAuthenticatedUser, GetAuthenticatedUserEngagement, GetAuthenticatedUserFavorites, GetAuthenticatedUserRecipes } from "../services/UserService";
import { isApiError, type ApiError } from "../types/api/error";
import { Logout } from "../services/AuthService";
import { useNavigate } from "react-router-dom";
@ -23,26 +23,16 @@ export default function Profile() {
const [activity, setActivity] = useState<Engagement[]>([]);
const [jwt, setJwt] = useState<string>("");
// BUG: Remove this, used for testing
useEffect(() => {
const eng: Engagement = {
Id: 1,
Type: "made",
Message: "Created some shit",
Entity: 1,
UserId: 1,
Created: new Date(),
};
setActivity([eng]);
}, []);
// Log the user out and direct to the home page
const logoutHandler = (): void => {
void Logout();
void navigate("/v2/web/home");
}
const seeAllRecipesHandler = (): void => void navigate("/v2/web/404");
const seeAllFavoritesHandler = (): void => void navigate("/v2/web/favorites");
const seeAllEngagementHandler = (): void => void navigate("/v2/web/404");
const fetchProfileData = async (): Promise<void> => {
const result_user: User | ApiError = await GetAuthenticatedUser();
if (isApiError(result_user)) {
@ -64,6 +54,13 @@ export default function Profile() {
} else {
setFavorites(result_favorites);
}
const result_engagement: Engagement[] | ApiError = await GetAuthenticatedUserEngagement();
if (isApiError(result_engagement)) {
setError(result_engagement.message);
} else {
setActivity(result_engagement);
}
}
// Get the JWT from the cookies
@ -83,6 +80,13 @@ export default function Profile() {
console.log("@error", error);
}, [error]);
useEffect(() => {
if (activity)
console.log("@activity", activity);
}, [activity]);
return (
<>
{/* User Details Section */}
@ -121,11 +125,11 @@ export default function Profile() {
) : (
recipes.slice(0, 4).map(recipe => <RecipeListItem key={recipe.Id} recipe={recipe} />)
)}
<a href="">
<button onClick={seeAllRecipesHandler} className="w-full">
<li className="w-full border-b border-gray-300 px-2 py-4 even:bg-gray-50 hover:bg-gray-100 hover:text-blue-600 duration-150 text-center">
See all...
</li>
</a>
</button>
</ul>
</section>
@ -138,11 +142,11 @@ export default function Profile() {
) : (
favorites.slice(0, 4).map(recipe => <RecipeListItem key={recipe.Id} recipe={recipe} />)
)}
<a href="">
<button onClick={seeAllFavoritesHandler} className="w-full">
<li className="w-full border-b border-gray-300 px-2 py-4 even:bg-gray-50 hover:bg-gray-100 hover:text-blue-600 duration-150 text-center">
See all...
</li>
</a>
</button>
</ul>
</section>
@ -151,11 +155,11 @@ export default function Profile() {
<h2 className="text-2xl font-semibold text-gray-800">Recent Activity</h2>
<ul className="w-full my-2">
{activity?.map(act => <ActivityListItem key={act.Id} engagement={act} />)}
<a href="">
<button onClick={seeAllEngagementHandler} className="w-full">
<li className="w-full border-b border-gray-300 px-2 py-4 even:bg-gray-50 hover:bg-gray-100 hover:text-blue-600 duration-150 text-center">
See all...
</li>
</a>
</button>
</ul>
</section>

View File

@ -1,8 +1,9 @@
import axios from "axios";
import type { ApiError } from "../types/api/error";
import type { User } from "../types/user";
import type { GetAuthenticateUserFavoritesResponse, GetAuthenticateUserRecipesResponse, GetAuthenticateUserResponse } from "../types/api/user";
import type { GetAuthenticateUserEngagementResponse, GetAuthenticateUserFavoritesResponse, GetAuthenticateUserMadeRecipesResponse, GetAuthenticateUserRecipesResponse, GetAuthenticateUserResponse, GetAuthenticateUserViewedRecipesResponse } from "../types/api/user";
import type { Recipe } from "../types/recipe";
import type { Engagement } from "../types/engagement";
export async function GetAuthenticatedUser(): Promise<User | ApiError> {
@ -46,3 +47,45 @@ export async function GetAuthenticatedUserFavorites(): Promise<Recipe[] | ApiErr
return response.data.favorites;
}
export async function GetAuthenticatedUserEngagement(): Promise<Engagement[] | ApiError> {
const response = await axios.get<GetAuthenticateUserEngagementResponse>("http://localhost:3000/v2/api/user/engagement");
if (response.data.status !== 200 || response.data.engagement === undefined) {
const err: ApiError = {
status: response.data.status,
message: response.data.message
};
return err;
}
return response.data.engagement;
}
export async function GetAuthenticatedUserMadeRecipes(): Promise<Recipe[] | ApiError> {
const response = await axios.get<GetAuthenticateUserMadeRecipesResponse>("http://localhost:3000/v2/api/user/recipes/made");
if (response.data.status !== 200 || response.data.recipes === undefined) {
const err: ApiError = {
status: response.data.status,
message: response.data.message
};
return err;
}
return response.data.recipes;
}
export async function GetAuthenticateUserViewedRecipes(): Promise<Recipe[] | ApiError> {
const response = await axios.get<GetAuthenticateUserViewedRecipesResponse>("http://localhost:3000/v2/api/user/recipes/viewed");
if (response.data.status !== 200 || response.data.recipes === undefined) {
const err: ApiError = {
status: response.data.status,
message: response.data.message
};
return err;
}
return response.data.recipes;
}

View File

@ -1,3 +1,4 @@
import type { Engagement } from "../engagement";
import type { Recipe } from "../recipe";
import type { User } from "../user";
@ -18,3 +19,21 @@ export interface GetAuthenticateUserFavoritesResponse {
message: string;
favorites?: Recipe[];
}
export interface GetAuthenticateUserEngagementResponse {
status: number;
message: string;
engagement?: Engagement[];
}
export interface GetAuthenticateUserMadeRecipesResponse {
status: number;
message: string;
recipes?: Recipe[];
}
export interface GetAuthenticateUserViewedRecipesResponse {
status: number;
message: string;
recipes?: Recipe[];
}