diff --git a/client/src/client/page/register_crew.gleam b/client/src/client/page/register_crew.gleam index 0219a3c..0f0b98f 100644 --- a/client/src/client/page/register_crew.gleam +++ b/client/src/client/page/register_crew.gleam @@ -126,15 +126,16 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { } UserClickedSearchLeader -> { - let url = contract.email_autocomplete_url + let value = model.crew_leader_email let query = uri.query_to_string([ - #("prefix", model.crew_leader_email), + #("q", "email"), + #("value", value), #("limit", "2"), ]) - let api = url <> "?" <> query + let api = contract.email_autocomplete_url <> "?" <> query let effect = decode.list(user.decoder()) diff --git a/server/src/server/router.gleam b/server/src/server/router.gleam index 96463ee..674ae82 100644 --- a/server/src/server/router.gleam +++ b/server/src/server/router.gleam @@ -5,8 +5,8 @@ import server/router/logout import server/router/register_crew import server/router/root import server/router/signup -import server/router/suggest_email import server/router/whoami +import server/user/autocomplete as user_autocomplete import wisp.{type Request, type Response} pub fn handle_request(req: Request, ctx: Context) -> Response { @@ -22,16 +22,15 @@ pub fn handle_request(req: Request, ctx: Context) -> Response { ["api", "logout"] -> logout.handle_request(req) // user + ["api", "user", "autocomplete"] -> + user_autocomplete.handle_request(req, ctx) + ["api", "user", id] -> find_user.handle_request(req, ctx, id) // crew ["api", "crew"] -> register_crew.handle_request(req, ctx) - // useful for autocomplete - ["api", "autocomplete", "email"] -> suggest_email.handle_request(req, ctx) - ["api", _] -> wisp.not_found() - _ -> root.handle_request(req, ctx) } } diff --git a/server/src/server/router/suggest_email.gleam b/server/src/server/router/suggest_email.gleam deleted file mode 100644 index 22f1617..0000000 --- a/server/src/server/router/suggest_email.gleam +++ /dev/null @@ -1,69 +0,0 @@ -import gleam/http -import gleam/int -import gleam/json -import gleam/list -import gleam/result -import server/context.{type Context} -import server/user -import shared/user as shared_user -import wisp.{type Request, type Response} - -type FindUserError { - MissingQuery - InvalidLimit -} - -type Query { - Prefix(prefix: String, limit: Int) -} - -pub fn handle_request(req: Request, ctx: Context) -> Response { - use <- wisp.require_method(req, http.Get) - - case parse_query(req) { - Ok(Prefix(prefix:, limit:)) -> by_email_prefix(ctx, prefix, limit) - Error(err) -> handle_err(err) - } -} - -fn parse_query(req: Request) -> Result(Query, FindUserError) { - let pairs = wisp.get_query(req) - - case pairs { - [#("prefix", value), ..] -> { - use limit_str <- result.try( - list.key_find(pairs, "limit") - // missing - |> result.replace_error(InvalidLimit), - ) - - use limit <- result.try( - int.parse(limit_str) - // not a number - |> result.replace_error(InvalidLimit), - ) - - Ok(Prefix(value, limit)) - } - - _ -> Error(MissingQuery) - } -} - -fn by_email_prefix(ctx: Context, prefix: String, limit: Int) -> Response { - case user.find_by_email_prefix(ctx.db, prefix:, limit:) { - Error(err) -> user.handle_error(err) - Ok(found) -> { - json.array(found, shared_user.to_json) - |> json.to_string - |> wisp.json_response(200) - } - } -} - -fn handle_err(err: FindUserError) -> Response { - case err { - MissingQuery -> wisp.bad_request("Missing query") - InvalidLimit -> wisp.bad_request("Provide a max number of results") - } -} diff --git a/server/src/server/user.gleam b/server/src/server/user.gleam index dc61e41..89862be 100644 --- a/server/src/server/user.gleam +++ b/server/src/server/user.gleam @@ -11,18 +11,18 @@ import wisp import youid/uuid pub type UserError { - DataBase(pog.QueryError) - /// User not found NotFound /// Phone needs to be unique PhoneAlreadytaken /// Email needs to be unique EmailAlreadyTaken + + DataBase(pog.QueryError) } /// Find an user -pub fn find(db: pog.Connection, id: uuid.Uuid) -> Result(User, UserError) { +pub fn find(db db: pog.Connection, id id: uuid.Uuid) -> Result(User, UserError) { use returned <- result.try( sql.find(db, id) |> result.map_error(DataBase), @@ -45,7 +45,7 @@ pub fn find(db: pog.Connection, id: uuid.Uuid) -> Result(User, UserError) { /// 󰇮 Find all users which email starts with the given prefix. /// You can limit the number of results. -pub fn find_by_email_prefix( +pub fn find_by_email( db db: pog.Connection, prefix prefix: String, limit max: Int, diff --git a/server/src/server/user/autocomplete.gleam b/server/src/server/user/autocomplete.gleam new file mode 100644 index 0000000..73a6eb2 --- /dev/null +++ b/server/src/server/user/autocomplete.gleam @@ -0,0 +1,105 @@ +import gleam/http +import gleam/int +import gleam/json +import gleam/list +import gleam/result +import server/auth +import server/context.{type Context} +import server/user +import shared/user as shared_user +import wisp.{type Request, type Response} + +type QueryParseError { + /// Missing `q` param + InvalidQuery + /// Missing `value` param + InvalidValue + /// Missing `limit` param + InvalidLimit +} + +type QueryParams { + EmailQuery(value: String, limit: Int) + NameQuery(value: String, limit: Int) +} + +type QueryKind { + Email + Name +} + +/// Autocompletion for registred users +/// +/// ## Usage: +/// GET /api/user/autocomplete?q=email&value=sigo@email.dev +/// GET /api/user/autocomplete?q=name&value=john +pub fn handle_request(req: Request, ctx: Context) -> Response { + use <- wisp.require_method(req, http.Get) + use <- auth.require_session(req) + + case parse_query(req) { + Ok(EmailQuery(value:, limit:)) -> query_by_email(ctx, value, limit) + Ok(NameQuery(value:, limit:)) -> query_by_name(ctx, value, limit) + Error(err) -> handle_error(err) + } +} + +fn query_by_name(ctx: Context, value: String, limit: Int) { + case user.find_by_name(db: ctx.db, name: value, limit:) { + Error(err) -> user.handle_error(err) + Ok(found) -> + json.array(found, shared_user.to_json) + |> json.to_string + |> wisp.json_response(200) + } +} + +fn query_by_email(ctx: Context, value: String, limit: Int) { + case user.find_by_email(ctx.db, prefix: value, limit:) { + Error(err) -> user.handle_error(err) + Ok(found) -> + json.array(found, shared_user.to_json) + |> json.to_string + |> wisp.json_response(200) + } +} + +fn parse_query(req: Request) -> Result(QueryParams, QueryParseError) { + let pairs = wisp.get_query(req) + + use query <- result.try(case list.key_find(pairs, "q") { + Ok("email") -> Ok(Email) + Ok("name") -> Ok(Name) + _ -> Error(InvalidQuery) + }) + + use value <- result.try(case list.key_find(pairs, "value") { + Ok(found) -> Ok(found) + Error(_) -> Error(InvalidValue) + }) + + use limit <- result.map(case list.key_find(pairs, "limit") { + Ok(found) -> parse_limit(found) + Error(_) -> Error(InvalidLimit) + }) + + case query { + Email -> EmailQuery(value:, limit:) + Name -> NameQuery(value:, limit:) + } +} + +fn parse_limit(limit_string: String) -> Result(Int, QueryParseError) { + case int.parse(limit_string) { + Ok(value) -> Ok(value) + Error(_) -> Error(InvalidLimit) + } +} + +fn handle_error(err: QueryParseError) -> Response { + case err { + InvalidQuery -> wisp.bad_request("Missing query: q") + InvalidValue -> wisp.bad_request("Missing query: value") + InvalidLimit -> wisp.bad_request("Missing query: limit") + } +} diff --git a/server/test/server_test.gleam b/server/test/server_test.gleam index cc560b1..46f4ddb 100644 --- a/server/test/server_test.gleam +++ b/server/test/server_test.gleam @@ -61,8 +61,8 @@ pub fn with_permissions( user_name: "test_dummy", user_role: role, user_password: password, - user_email: "test.dummy@email.com", - user_phone: "test-dummy-phone", + user_email: wisp.random_string(12) <> "@email.com", + user_phone: wisp.random_string(12), is_active: True, ) diff --git a/server/test/user_test.gleam b/server/test/user_test.gleam index 81e7ecb..3ca4827 100644 --- a/server/test/user_test.gleam +++ b/server/test/user_test.gleam @@ -2,6 +2,7 @@ import gleam/dynamic/decode import gleam/http import gleam/json import gleam/list +import gleam/uri import server/router import server/seed import server/user @@ -13,8 +14,6 @@ import wisp import wisp/simulate import youid/uuid -const test_email = "test@email.com" - pub fn signup_test() -> Nil { use ctx <- server_test.with_context() @@ -22,7 +21,7 @@ pub fn signup_test() -> Nil { signup_contract.RequestBody( name: wisp.random_string(12), role: role.None, - email: test_email, + email: wisp.random_string(12) <> "@email.com", phone: wisp.random_string(12), password: wisp.random_string(12), ) @@ -57,7 +56,7 @@ pub fn signup_invalid_email_test() -> Nil { signup_contract.RequestBody( name: wisp.random_string(12), role: role.None, - email: "invalid_email", + email: "some invalid email", phone: wisp.random_string(12), password: wisp.random_string(12), ) @@ -95,7 +94,7 @@ pub fn signup_email_conflict_test() -> Nil { server_test.with_permissions(next: req, ctx:, with: role.Admin) |> router.handle_request(ctx) - assert resp.status == 409 as "email needs to be unique" + assert resp.status == 409 as "Email needs to be unique" } pub fn signup_phone_conflict_test() -> Nil { @@ -105,7 +104,7 @@ pub fn signup_phone_conflict_test() -> Nil { signup_contract.RequestBody( name: wisp.random_string(12), role: role.None, - email: test_email, + email: wisp.random_string(12) <> "@email.com", phone: seed.phone, password: wisp.random_string(12), ) @@ -119,29 +118,35 @@ pub fn signup_phone_conflict_test() -> Nil { server_test.with_permissions(next: req, ctx:, with: role.Admin) |> router.handle_request(ctx) - assert resp.status == 409 as "phone needs to be unique" + assert resp.status == 409 } pub fn find_by_id_test() { use ctx <- server_test.with_context() + let path = "/api/user" let assert Ok(expected) = user.register( ctx:, - user_name: "dummy", + user_name: wisp.random_string(12), user_role: role.None, - user_password: "dummy_password", - user_email: "dummy@email.com", - user_phone: "dummy_phone", + user_password: wisp.random_string(12), + user_email: wisp.random_string(12) <> "@email.com", + user_phone: wisp.random_string(12), is_active: True, ) - let id = uuid.to_string(expected.id) - let req = - simulate.browser_request(http.Get, "/api/user/" <> id) - |> server_test.with_permissions(ctx, with: role.Admin) + let url = path <> "/" <> uuid.to_string(expected.id) + let req = simulate.browser_request(http.Get, url) let resp = router.handle_request(req, ctx) + assert resp.status == 401 + + let with_auth = + simulate.browser_request(http.Get, url) + |> server_test.with_permissions(ctx, with: role.Admin) + + let resp = router.handle_request(with_auth, ctx) assert resp.status == 200 let body = simulate.read_body(resp) @@ -150,57 +155,50 @@ pub fn find_by_id_test() { assert got == expected as "Found user" } -pub fn find_by_id_unauthorized_test() { - use ctx <- server_test.with_context() - - let assert Ok(expected) = - user.register( - ctx:, - user_name: "dummy", - user_role: role.None, - user_password: "dummy_password", - user_email: "dummy@email.com", - user_phone: "dummy_phone", - is_active: True, - ) - - let id = uuid.to_string(expected.id) - let req = simulate.browser_request(http.Get, "/api/user/" <> id) - - let resp = router.handle_request(req, ctx) - assert resp.status == 401 -} - -pub fn find_by_email_prefix_test() { +pub fn find_by_email_test() { use ctx <- server_test.with_context() + let path = "/api/user/autocomplete" + let prefix = "dummy_" + let first_email = prefix <> wisp.random_string(12) let assert Ok(first_dummy) = user.register( ctx:, - user_name: "dummy_one", + user_name: wisp.random_string(12), user_role: role.None, - user_password: "dummy_password", - user_email: "dummy_one@email.com", - user_phone: "dummy_one_phone", + user_password: wisp.random_string(12), + user_email: first_email <> "@email.com", + user_phone: wisp.random_string(12), is_active: True, ) + let second_email = prefix <> wisp.random_string(12) let assert Ok(second_dummy) = user.register( ctx:, - user_name: "dummy_two", + user_name: wisp.random_string(12), user_role: role.None, - user_password: "dummy_password", - user_email: "dummy_two@email.com", - user_phone: "dummy_two_phone", + user_password: wisp.random_string(12), + user_email: second_email <> "@email.com", + user_phone: wisp.random_string(12), is_active: True, ) - let req = - "/api/autocomplete/email?prefix=dummy&limit=2" - |> simulate.browser_request(http.Get, _) + let query = + uri.query_to_string([ + #("q", "email"), + #("value", prefix), + #("limit", "2"), + ]) + + let url = path <> "?" <> query + let req = simulate.browser_request(http.Get, url) let resp = router.handle_request(req, ctx) + assert resp.status == 401 + + let with_auth = server_test.with_permissions(req, ctx, role.None) + let resp = router.handle_request(with_auth, ctx) assert resp.status == 200 let body = simulate.read_body(resp) @@ -210,26 +208,64 @@ pub fn find_by_email_prefix_test() { as "Server found both users" } -pub fn find_by_prefix_invalid_query_test() { +pub fn find_by_email_invalid_query_test() { use ctx <- server_test.with_context() + let path = "/api/user/autocomplete" // missing query { - let req = - "/api/autocomplete/email" - |> simulate.browser_request(http.Get, _) + let query = + uri.query_to_string([ + #("value", "dummy"), + #("limit", "2"), + ]) + + let url = path <> "?" <> query + let req = simulate.browser_request(http.Get, url) + + let resp = router.handle_request(req, ctx) + assert resp.status == 401 + + let with_auth = server_test.with_permissions(req, ctx, role.None) + let resp = router.handle_request(with_auth, ctx) + assert resp.status == 400 + } + + // missing value + { + let query = + uri.query_to_string([ + #("q", "email"), + #("limit", "2"), + ]) + + let url = path <> "?" <> query + let req = simulate.browser_request(http.Get, url) let resp = router.handle_request(req, ctx) + assert resp.status == 401 + + let with_auth = server_test.with_permissions(req, ctx, role.None) + let resp = router.handle_request(with_auth, ctx) assert resp.status == 400 } // missing limit { - let req = - "/api/autocomplete/email?prefix=dummy" - |> simulate.browser_request(http.Get, _) + let query = + uri.query_to_string([ + #("q", "email"), + #("value", "dummy"), + ]) + + let url = path <> "?" <> query + let req = simulate.browser_request(http.Get, url) let resp = router.handle_request(req, ctx) - assert resp.status == 400 as "Require max number of results" + assert resp.status == 401 + + let with_auth = server_test.with_permissions(req, ctx, role.None) + let resp = router.handle_request(with_auth, ctx) + assert resp.status == 400 } } diff --git a/shared/src/shared/contract/register_crew.gleam b/shared/src/shared/contract/register_crew.gleam index ee4db16..00ccd48 100644 --- a/shared/src/shared/contract/register_crew.gleam +++ b/shared/src/shared/contract/register_crew.gleam @@ -5,7 +5,7 @@ import shared/user.{type User} pub const url = "/api/crew" -pub const email_autocomplete_url = "/api/autocomplete/email" +pub const email_autocomplete_url = "/api/email/autocomplete" // REQUEST ---------------------------------------------------------------------